From 0456cf9fbfe751e39ae8a74007b8d0fe9591a5ca Mon Sep 17 00:00:00 2001 From: Ari Bahtiar Date: Mon, 20 Jul 2026 09:56:34 +0700 Subject: [PATCH 1/5] oauth --- LICENSE | 21 +++ PLAN.md | 87 ++++++++++++ README.md | 182 +++++++++++++----------- package.json | 18 +++ src/commands/account.ts | 5 +- src/commands/balance.ts | 5 +- src/commands/beneficiary.ts | 11 +- src/commands/charge.ts | 11 +- src/commands/config.ts | 229 +++++++++++++++++++++++++++---- src/commands/customer.ts | 17 +-- src/commands/env.ts | 48 +++++++ src/commands/help.ts | 169 +++++++++++++++++++++++ src/commands/invoice.ts | 11 +- src/commands/listen.ts | 5 +- src/commands/login.ts | 128 +++++++++-------- src/commands/methods.ts | 5 +- src/commands/payment.ts | 14 +- src/commands/plan.ts | 14 +- src/commands/qr.ts | 5 +- src/commands/refund.ts | 5 +- src/commands/subscription.ts | 14 +- src/commands/transfer.ts | 14 +- src/index.ts | 38 +++-- src/lib/auth/browser.ts | 20 +++ src/lib/auth/oauth.ts | 169 +++++++++++++++++++++++ src/lib/auth/pkce.ts | 16 +++ src/lib/auth/token-manager.ts | 120 ++++++++++++++++ src/lib/client.ts | 60 ++++++-- src/lib/config.ts | 206 +++++++++++++++++++++++++-- src/lib/global-options.ts | 19 +++ src/lib/hitpay/client.ts | 88 ++++++++---- src/lib/hitpay/environments.ts | 95 +++++++++++++ src/lib/http-fetch.ts | 51 +++++++ src/lib/verify-api-key.ts | 17 +++ tests/lib/client.test.ts | 166 ++++++++++++++++++++++ tests/lib/config-command.test.ts | 172 +++++++++++++++++++++++ tests/lib/config-store.test.ts | 197 ++++++++++++++++++++++++++ tests/lib/config.test.ts | 160 +++++++++++++++------ tests/lib/env.test.ts | 134 ++++++++++++++++++ tests/lib/errors.test.ts | 5 + tests/lib/help.test.ts | 43 ++++++ tests/lib/login-command.test.ts | 58 ++++++++ tests/lib/oauth.test.ts | 15 ++ tests/lib/pkce.test.ts | 26 ++++ tests/lib/token-manager.test.ts | 119 ++++++++++++++++ 45 files changed, 2649 insertions(+), 363 deletions(-) create mode 100644 LICENSE create mode 100644 PLAN.md create mode 100644 src/commands/env.ts create mode 100644 src/commands/help.ts create mode 100644 src/lib/auth/browser.ts create mode 100644 src/lib/auth/oauth.ts create mode 100644 src/lib/auth/pkce.ts create mode 100644 src/lib/auth/token-manager.ts create mode 100644 src/lib/global-options.ts create mode 100644 src/lib/hitpay/environments.ts create mode 100644 src/lib/http-fetch.ts create mode 100644 src/lib/verify-api-key.ts create mode 100644 tests/lib/client.test.ts create mode 100644 tests/lib/config-command.test.ts create mode 100644 tests/lib/config-store.test.ts create mode 100644 tests/lib/env.test.ts create mode 100644 tests/lib/help.test.ts create mode 100644 tests/lib/login-command.test.ts create mode 100644 tests/lib/oauth.test.ts create mode 100644 tests/lib/pkce.test.ts create mode 100644 tests/lib/token-manager.test.ts diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5864dfb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 HitPay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..d21c4b8 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,87 @@ +# HitPay CLI — Internal Plan + +> Status: **Implemented (pre-publish)** · Last updated: 2026-07-19 + +## Summary + +Official CLI for HitPay payments, webhooks, and QR codes. Auth supports **OAuth (default login)** and **API key per environment profile**. Four hardcoded environments with profile-based config in `~/.hitpay/config.json`. + +## Environments + +| Environment | API base URL | +|-------------|--------------| +| `local` | `https://api.src.test` | +| `staging` | `https://api.staging.hit-pay.com` | +| `sandbox` | `https://api.sandbox.hit-pay.com` | +| `production` | `https://api.hit-pay.com` | + +- URLs are **hardcoded** in `src/lib/hitpay/environments.ts` (not `HITPAY_*` env vars). +- **Default active environment:** `production` when unset. + +## Config + +Stored at `~/.hitpay/config.json` (mode `0600`): + +```json +{ + "environment": "production", + "currency": "SGD", + "country": "SG", + "profiles": { + "sandbox": { + "api_key": "sk-...", + "salt": "...", + "oauth": { "access_token": "...", "refresh_token": "...", "expires_at": 0, "token_type": "Bearer" } + } + } +} +``` + +- Legacy flat `api_key` / `salt` at root is migrated into `profiles` on read. +- Switch default env: `hitpay env use sandbox` (not `config set environment`). + +## Authentication + +| Method | Command | Notes | +|--------|---------|-------| +| OAuth | `hitpay login` | Browser PKCE flow; tokens saved to active profile | +| API key | `hitpay config set api_key ` | Verified against API; takes priority over OAuth | +| Logout | `hitpay logout` / `hitpay logout --all` | Clears OAuth; `--all` also clears API key + salt | + +**Auth priority in `createClient()`:** + +1. `--api-key` flag (one-off override) +2. Profile `api_key` +3. Profile OAuth (auto-refresh) + +**401 suggestions:** OAuth → `hitpay login`; API key → `hitpay config set api_key`. + +## CLI UX + +```bash +hitpay env # show active environment +hitpay env use sandbox # switch default environment +hitpay login # OAuth for active environment +hitpay config set api_key sk-xxx # save API key to active profile +hitpay balance --env production # one-off env override (does not change default) +``` + +Global flags (one-off, not persisted): + +- `--env ` +- `--api-key ` +- `--json` + +## OAuth implementation + +- `src/lib/auth/pkce.ts` — PKCE S256 + state +- `src/lib/auth/oauth.ts` — localhost callback server + browser open +- `src/lib/auth/token-manager.ts` — refresh + expiry buffer +- `oauthClientId` per environment hardcoded in `OAUTH_CLIENT_IDS` (`environments.ts`) — baked into build + +## Pre-publish checklist + +- [ ] Register first-party OAuth app per environment +- [ ] Replace placeholder `OAUTH_CLIENT_IDS` with registered app IDs per environment +- [ ] CI workflow (test + typecheck) +- [ ] npm publish `@hit-pay/cli` diff --git a/README.md b/README.md index 759cccf..b135fb3 100644 --- a/README.md +++ b/README.md @@ -17,40 +17,105 @@ npx @hit-pay/cli --help ## Quick Start ```bash -# 1. Authenticate with your API key +# 1. Switch to sandbox (default active environment is production) +hitpay env use sandbox + +# 2. Sign in via browser (OAuth) hitpay login -# 2. Check your balance -hitpay balance +# Or set an API key instead (takes priority over OAuth) +hitpay config set api_key sk-sandbox-xxx -# 3. Create a payment +# 3. Check your account +hitpay whoami + +# 4. Create a payment hitpay payment create --amount 100 --currency SGD --email buyer@example.com -# 4. Generate a QR code in your terminal +# 5. Generate a QR code in your terminal hitpay qr create --amount 10 --currency SGD --method paynow_online -# 5. Listen for webhooks locally +# 6. Listen for webhooks locally hitpay listen --forward-to http://localhost:3000/webhook ``` -## Commands +## Authentication & configuration -### Authentication +Settings are stored in `~/.hitpay/config.json` (file mode `0600`). Each environment has its own profile (credentials, OAuth tokens, optional API URL override). + +### Environment ```bash -hitpay login # Interactive API key setup -hitpay login --api-key sk-live-xxx # Non-interactive -hitpay logout # Clear stored credentials -hitpay whoami # Show account info + environment +hitpay env # Show active environment +hitpay env use sandbox # Switch default environment +hitpay env use production # Switch back to production ``` -### Configuration +Supported environments: `local`, `staging`, `sandbox`, `production`. + +**Default active environment:** `production` (when unset in config). + +### Sign in ```bash -hitpay config set environment production # Switch sandbox <> production -hitpay config set currency SGD # Set default currency -hitpay config get environment +hitpay login # OAuth via browser (active environment) +hitpay login --oauth-port 8085 # Custom callback port +``` + +`hitpay login` uses the **active environment**. Switch first with `hitpay env use ` — do not pass `--env` to login. + +### API key (alternative to OAuth) + +```bash +hitpay config set api_key sk-xxx # Verify + save to active profile +hitpay config unset api_key # Remove from active profile +``` + +When both API key and OAuth exist for a profile, **API key is used first**. + +### Other config + +```bash +hitpay config set currency SGD +hitpay config set country SG +hitpay config set salt +hitpay config get api_key hitpay config list +hitpay config list --all # All environment profiles +hitpay logout # Clear OAuth tokens +hitpay logout --all # Clear OAuth + API key + salt +hitpay whoami # Account info + auth method +``` + +Use `hitpay env use ` to switch environments — not `config set environment`. + +## Global options + +| Flag | Description | +|------|-------------| +| `--env ` | One-off environment override for this command only | +| `--api-key ` | One-off API key override (not saved) | +| `--json` | Output results as JSON | +| `--help` | Show help | +| `--version` | Show CLI version | + +Examples: + +```bash +hitpay balance --env sandbox # Check sandbox without changing default +hitpay whoami --env production --json +hitpay balance --api-key "$CI_KEY" --env sandbox +``` + +Credentials are **not** read from `HITPAY_*` environment variables — use config commands or flags above. + +## Commands + +### Account + +```bash +hitpay balance +hitpay account ``` ### Payments @@ -67,17 +132,15 @@ hitpay payment cancel ```bash hitpay charge list --status succeeded --date-from 2026-03-01 --limit 20 -hitpay charge list --payment-method paynow_online --amount-from 100 -hitpay charge get # Includes fee breakdown +hitpay charge get hitpay charge export --date-from 2026-03-01 --date-to 2026-03-31 ``` ### Refunds ```bash -hitpay refund --payment-id --amount 50.00 # Partial refund -hitpay refund --payment-id # Full refund -hitpay refund --payment-id --yes # Skip confirmation +hitpay refund --payment-id --amount 50.00 +hitpay refund --payment-id --yes ``` ### Customers @@ -86,8 +149,6 @@ hitpay refund --payment-id --yes # Skip confirmation hitpay customer create --name "John Doe" --email john@example.com hitpay customer list --search "john" hitpay customer get -hitpay customer update --email new@example.com -hitpay customer delete ``` ### Invoices @@ -95,7 +156,6 @@ hitpay customer delete ```bash hitpay invoice create --amount 500 --currency SGD --customer-email john@example.com hitpay invoice list --status pending -hitpay invoice delete ``` ### Subscription Plans @@ -103,8 +163,6 @@ hitpay invoice delete ```bash hitpay plan create --name "Pro Monthly" --amount 49.99 --currency SGD --cycle monthly hitpay plan list -hitpay plan get -hitpay plan delete ``` ### Recurring Billing @@ -112,24 +170,14 @@ hitpay plan delete ```bash hitpay subscription create --plan-id --customer-email user@example.com hitpay subscription list --status active -hitpay subscription get -hitpay subscription cancel ``` ### Payouts ```bash -# Beneficiaries hitpay beneficiary create --country SG --currency SGD --holder-name "John" \ --account 1234567890 --bank-swift DBSSSGSG -hitpay beneficiary list -hitpay beneficiary delete - -# Transfers -hitpay transfer estimate --beneficiary-id --amount 1000 --currency SGD hitpay transfer create --beneficiary-id --amount 1000 --currency SGD -hitpay transfer list --status paid -hitpay transfer get ``` ### QR Codes @@ -138,61 +186,33 @@ hitpay transfer get hitpay qr create --amount 10 --currency SGD --method paynow_online ``` -Renders the QR code directly in your terminal. - ### Payment Methods ```bash -hitpay methods --country SG # PayNow, GrabPay, ShopeePay, cards -hitpay methods --country MY # FPX, GrabPay, Touch 'n Go, DuitNow -hitpay methods --country PH # GCash, QRPH, ShopeePay -hitpay methods --live # Show methods enabled on your account +hitpay methods --country SG +hitpay methods --live ``` -Supported countries: SG, MY, PH, TH, ID, VN, IN, AU. - ### Webhook Testing ```bash -# Forward webhooks to your local dev server hitpay listen --forward-to http://localhost:3000/webhook -hitpay listen --forward-to http://localhost:3000/webhook --events charge.created - -# Simulate webhook events hitpay trigger payment_request.completed -hitpay trigger charge.created --url http://localhost:3000/webhook -hitpay trigger --list # Show all 18 event types +hitpay trigger --list ``` -The `listen` command creates a tunnel via [localtunnel](https://github.com/localtunnel/localtunnel), registers it as a webhook endpoint with HitPay, and forwards received events to your local server. Press Ctrl+C to clean up. - -## Global Options - -| Flag | Description | -|------|-------------| -| `--json` | Output results as JSON (for scripting) | -| `--env ` | Override environment (`sandbox` or `production`) | -| `--help` | Show help for any command | -| `--version` | Show CLI version | - -## Configuration - -Credentials are stored in `~/.hitpay/config.json` with `0600` file permissions. +Run `hitpay help` for a grouped overview of all commands. -| Key | Description | Default | -|-----|-------------|---------| -| `api_key` | HitPay API key | — | -| `salt` | Webhook signature salt | — | -| `environment` | `sandbox` or `production` | `sandbox` | -| `currency` | Default currency code | — | -| `country` | Default country code | — | +## Configuration reference -Environment variables: - -```bash -export HITPAY_API_KEY=your-api-key -export HITPAY_ENVIRONMENT=sandbox -``` +| Key | Scope | Description | +|-----|-------|-------------| +| `environment` | Global | Active environment (`hitpay env use`) | +| `currency` | Global | Default currency code | +| `country` | Global | Default country code | +| `api_key` | Per env | API key (via `config set api_key`) | +| `salt` | Per env | Webhook signature salt | +| `oauth` | Per env | OAuth tokens (via `hitpay login`) | ## HitPay Developer Ecosystem @@ -209,9 +229,9 @@ export HITPAY_ENVIRONMENT=sandbox git clone https://github.com/hit-pay/cli.git cd cli npm install -npm run dev -- --help # Run from source -npm run build # Build with tsup -npm test # Run tests +npm run dev -- --help +npm run build +npm test ``` ## Requirements @@ -221,4 +241,4 @@ npm test # Run tests ## License -MIT +MIT — see [LICENSE](LICENSE). diff --git a/package.json b/package.json index 5f6c4f5..3804c1b 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "dev": "tsx src/index.ts", "build": "tsup", + "prepublishOnly": "npm run build && npm test", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit" @@ -21,10 +22,27 @@ "grabpay", "qr", "webhooks", + "oauth", "southeast-asia" ], "author": "HitPay", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/hit-pay/cli.git" + }, + "bugs": { + "url": "https://github.com/hit-pay/cli/issues" + }, + "homepage": "https://github.com/hit-pay/cli#readme", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], "engines": { "node": ">=18.0.0" }, diff --git a/src/commands/account.ts b/src/commands/account.ts index d077906..5bbc0bd 100644 --- a/src/commands/account.ts +++ b/src/commands/account.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; import chalk from 'chalk'; import type { AccountStatusResponse } from '../lib/hitpay/types.js'; -import { createClient } from '../lib/client.js'; +import { createClientFromCmd } from '../lib/client.js'; import { createSpinner } from '../lib/spinner.js'; import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; @@ -16,8 +16,7 @@ export function registerAccount(program: Command): void { .description('Show KYC verification and enabled payment providers') .action(async (_, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Fetching account status...'); spinner.start(); diff --git a/src/commands/balance.ts b/src/commands/balance.ts index b026e09..4f5e853 100644 --- a/src/commands/balance.ts +++ b/src/commands/balance.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; import type { BalanceResponse } from '../lib/hitpay/types.js'; import { formatCurrency } from '../lib/hitpay/formatters.js'; -import { createClient } from '../lib/client.js'; +import { createClientFromCmd } from '../lib/client.js'; import { createSpinner } from '../lib/spinner.js'; import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; @@ -12,8 +12,7 @@ export function registerBalance(program: Command): void { .description('Show account balances by currency') .action(async (_, cmd) => { try { - const globalOpts = cmd.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Fetching balances...'); spinner.start(); diff --git a/src/commands/beneficiary.ts b/src/commands/beneficiary.ts index 08cd0d1..c78d879 100644 --- a/src/commands/beneficiary.ts +++ b/src/commands/beneficiary.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import type { BeneficiaryResponse, CursorPaginatedResponse } from '../lib/hitpay/types.js'; -import { createClient } from '../lib/client.js'; +import { createClientFromCmd } from '../lib/client.js'; import { createSpinner } from '../lib/spinner.js'; import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; @@ -24,8 +24,7 @@ export function registerBeneficiary(program: Command): void { .option('--email ', 'Beneficiary email') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Creating beneficiary...'); spinner.start(); @@ -71,8 +70,7 @@ export function registerBeneficiary(program: Command): void { .option('--limit ', 'Number of results', '25') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Fetching beneficiaries...'); spinner.start(); @@ -110,8 +108,7 @@ export function registerBeneficiary(program: Command): void { .description('Delete a beneficiary') .action(async (id: string, _, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Deleting beneficiary...'); spinner.start(); diff --git a/src/commands/charge.ts b/src/commands/charge.ts index 4a66ce6..d989cab 100644 --- a/src/commands/charge.ts +++ b/src/commands/charge.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; import type { ChargeResponse, CursorPaginatedResponse } from '../lib/hitpay/types.js'; import { formatCurrency, formatPaymentMethod, redactCard } from '../lib/hitpay/formatters.js'; -import { createClient } from '../lib/client.js'; +import { createClientFromCmd } from '../lib/client.js'; import { createSpinner } from '../lib/spinner.js'; import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; @@ -25,8 +25,7 @@ export function registerCharge(program: Command): void { .option('--customer-id ', 'Filter by customer ID') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Fetching charges...'); spinner.start(); @@ -72,8 +71,7 @@ export function registerCharge(program: Command): void { .description('Get charge details with fee breakdown') .action(async (id: string, _, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Fetching charge...'); spinner.start(); @@ -121,8 +119,7 @@ export function registerCharge(program: Command): void { .option('--limit ', 'Number of results', '25') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Exporting charges...'); spinner.start(); diff --git a/src/commands/config.ts b/src/commands/config.ts index 88dc30c..7e3e32b 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,20 +1,140 @@ import { Command } from 'commander'; -import { readConfig, setConfigValue, getConfigValue, getConfigPath } from '../lib/config.js'; +import { + ENVIRONMENT_NAMES, + getApiBaseUrl, +} from '../lib/hitpay/environments.js'; +import { + getActiveEnvironment, + getAuthMethod, + getConfigPath, + getConfigValue, + getProfile, + maskSecret, + readConfig, + resolveEnvironment, + setGlobalValue, + setProfileValue, + unsetProfileValue, + type GlobalConfigKey, + type ProfileConfigKey, +} from '../lib/config.js'; +import { getGlobalOpts } from '../lib/global-options.js'; +import { verifyAndSaveApiKey } from '../lib/verify-api-key.js'; import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; +const GLOBAL_KEYS: GlobalConfigKey[] = ['currency', 'country']; +const PROFILE_KEYS: ProfileConfigKey[] = ['api_key', 'salt']; +const ALL_KEYS = [...GLOBAL_KEYS, ...PROFILE_KEYS] as const; + +function isGlobalKey(key: string): key is GlobalConfigKey { + return (GLOBAL_KEYS as string[]).includes(key); +} + +function isProfileKey(key: string): key is ProfileConfigKey { + return (PROFILE_KEYS as string[]).includes(key); +} + +function formatProfileForDisplay( + env: string, + profile: ReturnType, +): Record { + const config = readConfig(); + const display: Record = { environment: env }; + + if (profile.api_key) display.api_key = maskSecret(profile.api_key); + if (profile.salt) display.salt = maskSecret(profile.salt, 0, 4); + if (profile.oauth) { + display.oauth = 'connected'; + if (profile.oauth.business_id) display.business_id = profile.oauth.business_id; + } + + const authMethod = getAuthMethod(profile); + if (authMethod) display.auth_method = authMethod; + + display.api_url = getApiBaseUrl(env as ReturnType); + + if (env === getActiveEnvironment(config)) { + display.active = 'true'; + } + + return display; +} + +function resolveTargetEnvironment(cmd: Command): ReturnType { + const config = readConfig(); + const globalOpts = getGlobalOpts(cmd); + return resolveEnvironment(config, globalOpts.env); +} + export function registerConfig(program: Command): void { - const config = program - .command('config') - .description('Manage CLI configuration'); + const config = program.command('config').description('Manage CLI configuration'); config .command('set ') - .description('Set a config value (environment, currency, country)') - .action(async (key: string, value: string) => { + .description( + `Set a config value. Global: ${GLOBAL_KEYS.join(', ')}. Profile: ${PROFILE_KEYS.join(', ')}`, + ) + .action(async (key: string, value: string, _, cmd) => { + try { + if (key === 'environment') { + throw new Error( + 'Switch environment with `hitpay env use ` (local, staging, sandbox, production).', + ); + } + + if (!ALL_KEYS.includes(key as (typeof ALL_KEYS)[number])) { + throw new Error(`Invalid config key: ${key}. Valid keys: ${ALL_KEYS.join(', ')}`); + } + + if (isGlobalKey(key)) { + if (key === 'currency' || key === 'country') { + setGlobalValue(key, value.toUpperCase()); + } else { + setGlobalValue(key, value); + } + output.success(`Set ${key} = ${key === 'currency' || key === 'country' ? value.toUpperCase() : value}`); + return; + } + + const targetEnv = resolveTargetEnvironment(cmd); + + if (key === 'api_key') { + await verifyAndSaveApiKey(targetEnv, value); + output.success(`Set api_key for ${targetEnv}`); + return; + } + + setProfileValue(targetEnv, key as ProfileConfigKey, value); + output.success(`Set ${key} for ${targetEnv}`); + } catch (err) { + handleError(err); + } + }); + + config + .command('unset ') + .description( + `Remove a config value. Profile keys (${PROFILE_KEYS.join(', ')}, oauth) respect --env`, + ) + .action(async (key: string, _, cmd) => { try { - setConfigValue(key, value); - output.success(`Set ${key} = ${value}`); + if (key === 'oauth') { + const targetEnv = resolveTargetEnvironment(cmd); + unsetProfileValue(targetEnv, 'oauth'); + output.success(`Removed oauth for ${targetEnv}`); + return; + } + + if (!isProfileKey(key)) { + throw new Error( + `Invalid key: ${key}. Use profile keys: ${[...PROFILE_KEYS, 'oauth'].join(', ')}`, + ); + } + + const targetEnv = resolveTargetEnvironment(cmd); + unsetProfileValue(targetEnv, key); + output.success(`Removed ${key} for ${targetEnv}`); } catch (err) { handleError(err); } @@ -22,15 +142,53 @@ export function registerConfig(program: Command): void { config .command('get ') - .description('Get a config value') - .action(async (key: string) => { + .description('Get a config value (profile keys use active environment unless --env is set)') + .action(async (key: string, _, cmd) => { try { - const value = getConfigValue(key); - if (value !== undefined) { - console.log(value); - } else { + if ( + !ALL_KEYS.includes(key as (typeof ALL_KEYS)[number]) && + key !== 'oauth' && + key !== 'environment' + ) { + throw new Error(`Invalid config key: ${key}. Valid keys: ${[...ALL_KEYS, 'oauth', 'environment'].join(', ')}`); + } + + const targetEnv = resolveTargetEnvironment(cmd); + + if (key === 'environment') { + console.log(getActiveEnvironment(readConfig())); + return; + } + + if (key === 'oauth') { + const profile = getProfile(readConfig(), targetEnv); + if (profile.oauth) { + output.printData({ + environment: targetEnv, + oauth: 'connected', + business_id: profile.oauth.business_id, + }); + } else { + output.warn(`OAuth is not configured for ${targetEnv}`); + } + return; + } + + const value = isProfileKey(key) + ? getConfigValue(key, targetEnv) + : getConfigValue(key); + + if (value === undefined) { output.warn(`Config key "${key}" is not set`); + return; } + + if (key === 'api_key' || key === 'salt') { + console.log(key === 'salt' ? maskSecret(value, 0, 4) : maskSecret(value)); + return; + } + + console.log(value); } catch (err) { handleError(err); } @@ -38,21 +196,40 @@ export function registerConfig(program: Command): void { config .command('list') - .description('Show all config values') - .action(async () => { + .description('Show configuration') + .option('--all', 'Show all environment profiles') + .action(async (opts) => { try { const cfg = readConfig(); - const display: Record = {}; - for (const [key, value] of Object.entries(cfg)) { - if (key === 'api_key' && value) { - display[key] = value.slice(0, 8) + '...' + value.slice(-4); - } else if (key === 'salt' && value) { - display[key] = '****' + value.slice(-4); - } else if (value) { - display[key] = value; - } + + if (opts.all) { + const profiles = cfg.profiles ?? {}; + const entries = ENVIRONMENT_NAMES.filter((name) => profiles[name]).map((name) => + formatProfileForDisplay(name, getProfile(cfg, name)), + ); + + output.printData({ + active_environment: getActiveEnvironment(cfg), + currency: cfg.currency, + country: cfg.country, + profiles: entries, + config_path: getConfigPath(), + }); + return; } - display['config_path'] = getConfigPath(); + + const env = getActiveEnvironment(cfg); + const profile = getProfile(cfg, env); + const display: Record = { + environment: env, + config_path: getConfigPath(), + }; + + if (cfg.currency) display.currency = cfg.currency; + if (cfg.country) display.country = cfg.country; + + Object.assign(display, formatProfileForDisplay(env, profile)); + output.printData(display); } catch (err) { handleError(err); diff --git a/src/commands/customer.ts b/src/commands/customer.ts index 11462e7..a4f8df2 100644 --- a/src/commands/customer.ts +++ b/src/commands/customer.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import type { CustomerResponse, CursorPaginatedResponse } from '../lib/hitpay/types.js'; -import { createClient } from '../lib/client.js'; +import { createClientFromCmd } from '../lib/client.js'; import { createSpinner } from '../lib/spinner.js'; import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; @@ -23,8 +23,7 @@ export function registerCustomer(program: Command): void { .option('--country ', 'Country code') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Creating customer...'); spinner.start(); @@ -69,8 +68,7 @@ export function registerCustomer(program: Command): void { .option('--limit ', 'Number of results', '25') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Fetching customers...'); spinner.start(); @@ -108,8 +106,7 @@ export function registerCustomer(program: Command): void { .description('Get customer details') .action(async (id: string, _, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Fetching customer...'); spinner.start(); @@ -150,8 +147,7 @@ export function registerCustomer(program: Command): void { .option('--country ', 'Country code') .action(async (id: string, opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Updating customer...'); spinner.start(); @@ -182,8 +178,7 @@ export function registerCustomer(program: Command): void { .description('Delete a customer') .action(async (id: string, _, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Deleting customer...'); spinner.start(); diff --git a/src/commands/env.ts b/src/commands/env.ts new file mode 100644 index 0000000..ff21458 --- /dev/null +++ b/src/commands/env.ts @@ -0,0 +1,48 @@ +import { Command } from 'commander'; +import { PUBLIC_ENVIRONMENT_NAMES } from '../lib/hitpay/environments.js'; +import { + getActiveEnvironment, + getConfigPath, + readConfig, + setGlobalValue, +} from '../lib/config.js'; +import { parseLoginEnvironment } from '../lib/auth/oauth.js'; +import * as output from '../lib/output.js'; +import { handleError } from '../lib/errors.js'; + +function showCurrentEnvironment(): void { + const config = readConfig(); + output.printData({ + active_environment: getActiveEnvironment(config), + config_path: getConfigPath(), + }); +} + +function switchEnvironment(environment: string): void { + const parsed = parseLoginEnvironment(environment); + setGlobalValue('environment', parsed); + output.success(`Active environment set to ${parsed}`); +} + +export function registerEnv(program: Command): void { + const env = program.command('env').description('Show or switch active environment'); + + env.action(async () => { + try { + showCurrentEnvironment(); + } catch (err) { + handleError(err); + } + }); + + env + .command('use ') + .description(`Switch active environment (${PUBLIC_ENVIRONMENT_NAMES.join(', ')})`) + .action(async (environment: string) => { + try { + switchEnvironment(environment); + } catch (err) { + handleError(err); + } + }); +} diff --git a/src/commands/help.ts b/src/commands/help.ts new file mode 100644 index 0000000..6903e4d --- /dev/null +++ b/src/commands/help.ts @@ -0,0 +1,169 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { PUBLIC_ENVIRONMENT_NAMES } from '../lib/hitpay/environments.js'; + +const COMMAND_GROUPS: { title: string; commands: string[]; expanded?: boolean }[] = [ + { + title: 'Authentication & configuration', + commands: ['env', 'login', 'logout', 'whoami', 'config'], + expanded: true, + }, + { + title: 'Account', + commands: ['balance', 'account'], + }, + { + title: 'Payments', + commands: ['payment', 'charge', 'refund', 'qr', 'methods'], + }, + { + title: 'Customers & billing', + commands: ['customer', 'invoice', 'plan', 'subscription'], + }, + { + title: 'Payouts', + commands: ['beneficiary', 'transfer'], + }, + { + title: 'Webhooks', + commands: ['listen', 'trigger'], + }, +]; + +const AUTH_SIMPLE_COMMANDS = ['login', 'logout', 'whoami'] as const; + +interface HelpBlock { + name: string; + description: string; + usage: string[]; + notes?: string[]; +} + +function getAuthBlocks(): HelpBlock[] { + const envList = PUBLIC_ENVIRONMENT_NAMES.join(' | '); + + return [ + { + name: 'env', + description: 'Show or switch active environment', + usage: ['hitpay env', `hitpay env use ${envList}`], + }, + { + name: 'config', + description: 'Manage persistent settings (~/.hitpay/config.json)', + usage: [ + 'hitpay config set api_key, salt, currency, country', + 'hitpay config get profile keys respect --env', + 'hitpay config unset api_key, salt, oauth', + 'hitpay config list [--all]', + ], + notes: [ + 'environment is read-only — use `hitpay env use ` to switch', + 'profile keys target the active environment unless --env is set', + ], + }, + ]; +} + +function findCommand(root: Command, path: string[]): Command | undefined { + let current: Command = root; + for (const segment of path) { + const next = current.commands.find((cmd) => cmd.name() === segment); + if (!next) return undefined; + current = next; + } + return current; +} + +function printSimpleCommand(name: string, description: string, indent = ' '): void { + console.log(`${indent}${chalk.green(name.padEnd(16))}${description}`); +} + +function printHelpBlock(block: HelpBlock): void { + console.log(` ${chalk.green.bold(block.name)}`); + console.log(` ${block.description}`); + console.log(); + + for (const line of block.usage) { + console.log(chalk.dim(` ${line}`)); + } + + if (block.notes?.length) { + console.log(); + for (const line of block.notes) { + console.log(chalk.dim(` ${line}`)); + } + } + + console.log(); +} + +function printAuthGroup(program: Command): void { + console.log(chalk.bold.cyan('Authentication & configuration')); + console.log(); + + const [envBlock, configBlock] = getAuthBlocks(); + + printHelpBlock(envBlock); + + for (const name of AUTH_SIMPLE_COMMANDS) { + const cmd = program.commands.find((c) => c.name() === name); + if (cmd) printSimpleCommand(name, cmd.description()); + } + + console.log(); + printHelpBlock(configBlock); +} + +function printOverview(program: Command): void { + const version = program.version() || ''; + console.log(chalk.bold(`HitPay CLI${version ? ` v${version}` : ''}`)); + console.log(program.description()); + console.log(); + + for (const group of COMMAND_GROUPS) { + if (group.expanded) { + printAuthGroup(program); + continue; + } + + console.log(chalk.bold.cyan(group.title)); + console.log(); + + for (const name of group.commands) { + const cmd = program.commands.find((c) => c.name() === name); + if (!cmd) continue; + printSimpleCommand(name, cmd.description()); + } + + console.log(); + } + + console.log(chalk.dim('Global options:')); + console.log(chalk.dim(' --env (one-off) --json --api-key')); + console.log(); + console.log(chalk.dim('Run `hitpay help ` for command details.')); + console.log(chalk.dim('Run `hitpay --help` for subcommands.')); +} + +export function registerHelp(program: Command): void { + program.addHelpCommand(false); + + program + .command('help [command...]') + .description('Show all commands or help for a specific command') + .action((commandPath: string[]) => { + if (commandPath.length > 0) { + const target = findCommand(program, commandPath); + if (!target) { + console.error(chalk.red(`Unknown command: ${commandPath.join(' ')}`)); + console.error(chalk.dim('Run `hitpay help` to see all commands.')); + process.exit(1); + } + target.outputHelp(); + return; + } + + printOverview(program); + }); +} diff --git a/src/commands/invoice.ts b/src/commands/invoice.ts index a7e1867..1af4b20 100644 --- a/src/commands/invoice.ts +++ b/src/commands/invoice.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; import type { InvoiceResponse, CursorPaginatedResponse } from '../lib/hitpay/types.js'; import { formatCurrency } from '../lib/hitpay/formatters.js'; -import { createClient } from '../lib/client.js'; +import { createClientFromCmd } from '../lib/client.js'; import { createSpinner } from '../lib/spinner.js'; import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; @@ -26,8 +26,7 @@ export function registerInvoice(program: Command): void { .option('--send-email', 'Send invoice via email') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Creating invoice...'); spinner.start(); @@ -76,8 +75,7 @@ export function registerInvoice(program: Command): void { .option('--status ', 'Filter by status') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Fetching invoices...'); spinner.start(); @@ -116,8 +114,7 @@ export function registerInvoice(program: Command): void { .description('Delete an invoice') .action(async (id: string, _, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Deleting invoice...'); spinner.start(); diff --git a/src/commands/listen.ts b/src/commands/listen.ts index bec4a32..567e882 100644 --- a/src/commands/listen.ts +++ b/src/commands/listen.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; import chalk from 'chalk'; import type { WebhookEventResponse } from '../lib/hitpay/types.js'; -import { createClient } from '../lib/client.js'; +import { createClientFromCmd } from '../lib/client.js'; import { createSpinner } from '../lib/spinner.js'; import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; @@ -19,8 +19,7 @@ export function registerListen(program: Command): void { .option('--port ', 'Local port for the webhook receiver', '0') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const forwardUrl = opts.forwardTo; const eventFilter = opts.events ? opts.events.split(',').map((e: string) => e.trim()) : undefined; diff --git a/src/commands/login.ts b/src/commands/login.ts index 4e1a969..e50444d 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,56 +1,46 @@ import { Command } from 'commander'; -import { input, password } from '@inquirer/prompts'; -import { HitPayClient } from '../lib/hitpay/client.js'; -import type { Environment } from '../lib/hitpay/client.js'; -import { readConfig, writeConfig } from '../lib/config.js'; -import { createClient } from '../lib/client.js'; +import { + clearProfileAuth, + getActiveEnvironment, + getAuthMethod, + getConfigPath, + getProfile, + readConfig, + resolveEnvironment, +} from '../lib/config.js'; +import { createClientFromCmd, getResolvedApiUrl } from '../lib/client.js'; +import { loginWithOAuth } from '../lib/auth/oauth.js'; +import { getGlobalOpts } from '../lib/global-options.js'; import { createSpinner } from '../lib/spinner.js'; import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; +const AUTH_HINT = 'Run `hitpay login` or `hitpay config set api_key `.'; + export function registerLogin(program: Command): void { program .command('login') - .description('Authenticate with your HitPay API key') - .option('--api-key ', 'HitPay API key (or enter interactively)') - .option('--salt ', 'Webhook signature salt') - .option('--environment ', 'sandbox or production', 'sandbox') - .action(async (opts) => { + .description('Sign in via browser (OAuth)') + .option('--oauth-port ', 'Local OAuth callback port', '8085') + .action(async (opts, cmd) => { try { - let apiKey = opts.apiKey; - let salt = opts.salt; - const env = opts.environment as Environment; - - if (!apiKey) { - apiKey = await password({ - message: 'Enter your HitPay API key:', - mask: '*', - }); + const globalOpts = getGlobalOpts(cmd); + if (globalOpts.env) { + throw new Error( + 'Switch environment first with `hitpay env use `, then run `hitpay login`.', + ); } - if (!salt) { - salt = await password({ - message: 'Enter your webhook salt (optional, press Enter to skip):', - mask: '*', - }); - } + const config = readConfig(); + const env = getActiveEnvironment(config); - const spinner = createSpinner('Verifying API key...'); + const spinner = createSpinner('Opening browser for sign-in...'); spinner.start(); - const client = new HitPayClient(apiKey.trim(), env); - const valid = await client.verifyConnection(); - - if (!valid) { - spinner.fail('Invalid API key or unable to connect'); - process.exit(1); - } - - const config = readConfig(); - config.api_key = apiKey.trim(); - config.environment = env; - if (salt) config.salt = salt.trim(); - writeConfig(config); + await loginWithOAuth({ + environment: env, + port: Number(opts.oauthPort), + }); spinner.succeed(`Authenticated with HitPay (${env})`); } catch (err) { @@ -62,14 +52,20 @@ export function registerLogin(program: Command): void { export function registerLogout(program: Command): void { program .command('logout') - .description('Remove stored HitPay credentials') - .action(async () => { + .description('Remove stored credentials for the active environment') + .option('--all', 'Also remove API key and webhook salt') + .action(async (opts, cmd) => { try { const config = readConfig(); - delete config.api_key; - delete config.salt; - writeConfig(config); - output.success('Logged out. API key removed.'); + const globalOpts = getGlobalOpts(cmd); + const env = resolveEnvironment(config, globalOpts.env); + clearProfileAuth(env, Boolean(opts.all)); + + if (opts.all) { + output.success(`Logged out of ${env} (OAuth, API key, and salt removed).`); + } else { + output.success(`Logged out of ${env} (OAuth tokens removed).`); + } } catch (err) { handleError(err); } @@ -82,34 +78,50 @@ export function registerWhoami(program: Command): void { .description('Show current account info and environment') .action(async (_, cmd) => { try { - const globalOpts = cmd.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const globalOpts = getGlobalOpts(cmd); + const config = readConfig(); + const env = resolveEnvironment(config, globalOpts.env); + const profile = getProfile(config, env); + const authMethod = getAuthMethod(profile); + + if (!authMethod && !globalOpts.apiKey) { + output.error(`Not authenticated for ${env}. ${AUTH_HINT}`); + process.exit(1); + } const spinner = createSpinner('Fetching account info...'); spinner.start(); - const info = await client.get<{ - id?: string; - business_name?: string; - email?: string; - country?: string; - }>('/v1/basicinfo').catch(() => null); + const client = await createClientFromCmd(cmd); + + const infoPath = client.authMethod === 'oauth' ? '/v1/info' : '/v1/account-status'; + const info = await client + .get<{ + id?: string; + name?: string; + display_name?: string; + business_name?: string; + email?: string; + country?: string; + }>(infoPath) + .catch(() => null); - // Fallback: verify connection works const verified = info ? true : await client.verifyConnection(); spinner.stop(); if (!verified) { - output.error('Unable to connect. Run `hitpay login` to re-authenticate.'); + output.error('Unable to connect. Re-authenticate for this environment.'); process.exit(1); } - const config = readConfig(); output.printData({ - environment: config.environment || 'sandbox', + environment: env, + active_environment: getActiveEnvironment(config), + auth_method: client.authMethod, + api_url: getResolvedApiUrl(globalOpts.env), ...(info || {}), - config_path: '~/.hitpay/config.json', + config_path: getConfigPath(), }); } catch (err) { handleError(err); diff --git a/src/commands/methods.ts b/src/commands/methods.ts index 4c3ca79..de25c71 100644 --- a/src/commands/methods.ts +++ b/src/commands/methods.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import chalk from 'chalk'; import { formatPaymentMethod } from '../lib/hitpay/formatters.js'; import type { AccountStatusResponse } from '../lib/hitpay/types.js'; -import { createClient } from '../lib/client.js'; +import { createClientFromCmd } from '../lib/client.js'; import { createSpinner } from '../lib/spinner.js'; import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; @@ -64,8 +64,7 @@ export function registerMethods(program: Command): void { try { if (opts.live) { // Fetch live account status to check enabled providers - const globalOpts = cmd.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Checking enabled methods...'); spinner.start(); diff --git a/src/commands/payment.ts b/src/commands/payment.ts index 2eb740d..0ea9e61 100644 --- a/src/commands/payment.ts +++ b/src/commands/payment.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; import type { PaymentRequestResponse, PagePaginatedResponse } from '../lib/hitpay/types.js'; import { formatCurrency, formatPaymentMethod } from '../lib/hitpay/formatters.js'; -import { createClient } from '../lib/client.js'; +import { createClientFromCmd } from '../lib/client.js'; import { createSpinner } from '../lib/spinner.js'; import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; @@ -28,8 +28,7 @@ export function registerPayment(program: Command): void { .option('--expiry ', 'Expire after N minutes') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Creating payment request...'); spinner.start(); @@ -80,8 +79,7 @@ export function registerPayment(program: Command): void { .description('Get payment request details') .action(async (id: string, _, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Fetching payment...'); spinner.start(); @@ -117,8 +115,7 @@ export function registerPayment(program: Command): void { .option('--page ', 'Page number', '1') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Fetching payments...'); spinner.start(); @@ -164,8 +161,7 @@ export function registerPayment(program: Command): void { .description('Cancel/delete a pending payment request') .action(async (id: string, _, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Canceling payment...'); spinner.start(); diff --git a/src/commands/plan.ts b/src/commands/plan.ts index b7fe752..fe31706 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import { formatCurrency } from '../lib/hitpay/formatters.js'; -import { createClient } from '../lib/client.js'; +import { createClientFromCmd } from '../lib/client.js'; import { createSpinner } from '../lib/spinner.js'; import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; @@ -39,8 +39,7 @@ export function registerPlan(program: Command): void { .option('--cycle-repeat ', 'Number of billing cycles (0 = unlimited)') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Creating plan...'); spinner.start(); @@ -80,8 +79,7 @@ export function registerPlan(program: Command): void { .description('List subscription plans') .action(async (_, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Fetching plans...'); spinner.start(); @@ -114,8 +112,7 @@ export function registerPlan(program: Command): void { .description('Get subscription plan details') .action(async (id: string, _, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Fetching plan...'); spinner.start(); @@ -148,8 +145,7 @@ export function registerPlan(program: Command): void { .description('Delete a subscription plan') .action(async (id: string, _, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Deleting plan...'); spinner.start(); diff --git a/src/commands/qr.ts b/src/commands/qr.ts index 41e42b7..975bb28 100644 --- a/src/commands/qr.ts +++ b/src/commands/qr.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; import qrTerminal from 'qrcode-terminal'; import { formatCurrency, formatPaymentMethod } from '../lib/hitpay/formatters.js'; -import { createClient } from '../lib/client.js'; +import { createClientFromCmd } from '../lib/client.js'; import { createSpinner } from '../lib/spinner.js'; import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; @@ -39,8 +39,7 @@ export function registerQr(program: Command): void { .option('--reference ', 'Reference number') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Generating QR code...'); spinner.start(); diff --git a/src/commands/refund.ts b/src/commands/refund.ts index cc4ec2b..22fae07 100644 --- a/src/commands/refund.ts +++ b/src/commands/refund.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import { confirm } from '@inquirer/prompts'; import type { ChargeResponse, RefundResponse } from '../lib/hitpay/types.js'; import { formatCurrency } from '../lib/hitpay/formatters.js'; -import { createClient } from '../lib/client.js'; +import { createClientFromCmd } from '../lib/client.js'; import { createSpinner } from '../lib/spinner.js'; import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; @@ -17,8 +17,7 @@ export function registerRefund(program: Command): void { .option('--yes', 'Skip confirmation prompt') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); // Fetch charge details first to show what we're refunding const spinner = createSpinner('Fetching charge details...'); diff --git a/src/commands/subscription.ts b/src/commands/subscription.ts index 6f1e8dd..e7134d6 100644 --- a/src/commands/subscription.ts +++ b/src/commands/subscription.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; import type { RecurringBillingResponse, PagePaginatedResponse } from '../lib/hitpay/types.js'; import { formatCurrency } from '../lib/hitpay/formatters.js'; -import { createClient } from '../lib/client.js'; +import { createClientFromCmd } from '../lib/client.js'; import { createSpinner } from '../lib/spinner.js'; import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; @@ -21,8 +21,7 @@ export function registerSubscription(program: Command): void { .option('--send-email', 'Send notification email') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Creating subscription...'); spinner.start(); @@ -66,8 +65,7 @@ export function registerSubscription(program: Command): void { .option('--limit ', 'Number of results', '25') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Fetching subscriptions...'); spinner.start(); @@ -113,8 +111,7 @@ export function registerSubscription(program: Command): void { .description('Get subscription details') .action(async (id: string, _, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Fetching subscription...'); spinner.start(); @@ -150,8 +147,7 @@ export function registerSubscription(program: Command): void { .description('Cancel a subscription') .action(async (id: string, _, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Canceling subscription...'); spinner.start(); diff --git a/src/commands/transfer.ts b/src/commands/transfer.ts index aa944f8..a952b7d 100644 --- a/src/commands/transfer.ts +++ b/src/commands/transfer.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import { confirm } from '@inquirer/prompts'; import type { TransferResponse, TransferEstimateResponse, CursorPaginatedResponse } from '../lib/hitpay/types.js'; import { formatCurrency } from '../lib/hitpay/formatters.js'; -import { createClient } from '../lib/client.js'; +import { createClientFromCmd } from '../lib/client.js'; import { createSpinner } from '../lib/spinner.js'; import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; @@ -20,8 +20,7 @@ export function registerTransfer(program: Command): void { .requiredOption('--currency ', 'Source currency') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Estimating fees...'); spinner.start(); @@ -61,8 +60,7 @@ export function registerTransfer(program: Command): void { .option('--yes', 'Skip confirmation prompt') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const amount = parseFloat(opts.amount); const currency = opts.currency.toUpperCase(); @@ -137,8 +135,7 @@ export function registerTransfer(program: Command): void { .option('--limit ', 'Number of results', '25') .action(async (opts, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Fetching transfers...'); spinner.start(); @@ -177,8 +174,7 @@ export function registerTransfer(program: Command): void { .description('Get transfer details') .action(async (id: string, _, cmd) => { try { - const globalOpts = cmd.parent?.parent?.opts() || {}; - const client = createClient({ environment: globalOpts.env }); + const client = await createClientFromCmd(cmd); const spinner = createSpinner('Fetching transfer...'); spinner.start(); diff --git a/src/index.ts b/src/index.ts index e5c0900..c544683 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,11 @@ +import { createRequire } from 'node:module'; import { Command } from 'commander'; +import { PUBLIC_ENVIRONMENT_NAMES } from './lib/hitpay/environments.js'; import { setJsonMode } from './lib/output.js'; import { handleError } from './lib/errors.js'; +import { getGlobalOpts } from './lib/global-options.js'; import { registerLogin, registerLogout, registerWhoami } from './commands/login.js'; +import { registerEnv } from './commands/env.js'; import { registerConfig } from './commands/config.js'; import { registerBalance } from './commands/balance.js'; import { registerAccount } from './commands/account.js'; @@ -18,55 +22,49 @@ import { registerQr } from './commands/qr.js'; import { registerMethods } from './commands/methods.js'; import { registerListen } from './commands/listen.js'; import { registerTrigger } from './commands/trigger.js'; +import { registerHelp } from './commands/help.js'; + +const require = createRequire(import.meta.url); +const { version } = require('../package.json') as { version: string }; const program = new Command(); program .name('hitpay') .description('HitPay CLI — manage payments, test webhooks, and generate QR codes') - .version('0.1.0') + .version(version) .option('--json', 'Output results as JSON') - .option('--env ', 'Override environment (sandbox or production)') - .hook('preAction', (thisCommand) => { - const opts = thisCommand.opts(); + .option( + '--env ', + `One-off profile override for this command only (${PUBLIC_ENVIRONMENT_NAMES.join(', ')})`, + ) + .option('--api-key ', 'API key override for this command (not saved)') + .hook('preAction', (_thisCommand, actionCommand) => { + const opts = getGlobalOpts(actionCommand); if (opts.json) setJsonMode(true); }); -// Auth & config +registerEnv(program); registerLogin(program); registerLogout(program); registerWhoami(program); registerConfig(program); -// Account registerBalance(program); registerAccount(program); - -// Payments registerPayment(program); registerCharge(program); registerRefund(program); - -// Customers registerCustomer(program); - -// Invoices registerInvoice(program); - -// Subscriptions registerPlan(program); registerSubscription(program); - -// Payouts registerBeneficiary(program); registerTransfer(program); - -// QR & methods registerQr(program); registerMethods(program); - -// Webhooks registerListen(program); registerTrigger(program); +registerHelp(program); program.parseAsync(process.argv).catch(handleError); diff --git a/src/lib/auth/browser.ts b/src/lib/auth/browser.ts new file mode 100644 index 0000000..7f32a10 --- /dev/null +++ b/src/lib/auth/browser.ts @@ -0,0 +1,20 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +export async function openBrowser(url: string): Promise { + const platform = process.platform; + + if (platform === 'darwin') { + await execFileAsync('open', [url]); + return; + } + + if (platform === 'win32') { + await execFileAsync('cmd', ['/c', 'start', '', url]); + return; + } + + await execFileAsync('xdg-open', [url]); +} diff --git a/src/lib/auth/oauth.ts b/src/lib/auth/oauth.ts new file mode 100644 index 0000000..b3dca7c --- /dev/null +++ b/src/lib/auth/oauth.ts @@ -0,0 +1,169 @@ +import { createServer, type Server } from 'node:http'; +import { URL } from 'node:url'; +import { + type Environment, + ENVIRONMENT_NAMES, + getOAuthAuthorizeUrl, + getOAuthClientId, + hasOAuthClientId, + isEnvironment, + OAUTH_LOGIN_SCOPE, +} from '../hitpay/environments.js'; +import { setProfileOAuth } from '../config.js'; +import { HitPayClient } from '../hitpay/client.js'; +import { openBrowser } from './browser.js'; +import { exchangeAuthorizationCode } from './token-manager.js'; +import { generateOAuthState, generatePkce } from './pkce.js'; + +const DEFAULT_OAUTH_PORT = 8085; +const OAUTH_CALLBACK_PATH = '/callback'; + +export interface OAuthLoginOptions { + environment: Environment; + port?: number; +} + +export interface OAuthLoginResult { + environment: Environment; + businessId?: string; +} + +function buildRedirectUri(port: number): string { + return `http://127.0.0.1:${port}${OAUTH_CALLBACK_PATH}`; +} + +function buildAuthorizeUrl( + env: Environment, + redirectUri: string, + state: string, + challenge: string, + clientId: string, +): string { + const url = new URL(getOAuthAuthorizeUrl(env)); + url.searchParams.set('client_id', clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', OAUTH_LOGIN_SCOPE); + url.searchParams.set('state', state); + url.searchParams.set('code_challenge', challenge); + url.searchParams.set('code_challenge_method', 'S256'); + return url.toString(); +} + +function waitForCallback(port: number, expectedState: string): Promise<{ code: string }> { + return new Promise((resolve, reject) => { + let server: Server | undefined; + + const cleanup = () => { + server?.close(); + }; + + server = createServer((req, res) => { + try { + if (!req.url) { + res.writeHead(400); + res.end('Bad request'); + return; + } + + const incoming = new URL(req.url, `http://127.0.0.1:${port}`); + + if (incoming.pathname !== OAUTH_CALLBACK_PATH) { + res.writeHead(404); + res.end('Not found'); + return; + } + + const error = incoming.searchParams.get('error'); + if (error) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end(`Authorization denied: ${error}`); + cleanup(); + reject(new Error(`OAuth authorization denied: ${error}`)); + return; + } + + const state = incoming.searchParams.get('state'); + const code = incoming.searchParams.get('code'); + + if (!state || state !== expectedState) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Invalid OAuth state'); + cleanup(); + reject(new Error('OAuth state mismatch — possible CSRF attempt.')); + return; + } + + if (!code) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Missing authorization code'); + cleanup(); + reject(new Error('OAuth callback missing authorization code.')); + return; + } + + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end( + '

HitPay CLI

Authenticated. You can close this window.

', + ); + cleanup(); + resolve({ code }); + } catch (err) { + cleanup(); + reject(err); + } + }); + + server.on('error', reject); + server.listen(port, '127.0.0.1'); + }); +} + +export async function loginWithOAuth(options: OAuthLoginOptions): Promise { + const env = options.environment; + const port = options.port ?? DEFAULT_OAUTH_PORT; + const redirectUri = buildRedirectUri(port); + const { verifier, challenge } = generatePkce(); + const state = generateOAuthState(); + + const clientId = getOAuthClientId(env); + if (!hasOAuthClientId(env)) { + throw new Error( + `OAuth is not configured for "${env}". Use \`hitpay config set api_key\` instead.`, + ); + } + + const authorizeUrl = buildAuthorizeUrl(env, redirectUri, state, challenge, clientId); + const callbackPromise = waitForCallback(port, state); + + await openBrowser(authorizeUrl); + const { code } = await callbackPromise; + + const tokens = await exchangeAuthorizationCode(env, code, redirectUri, verifier); + setProfileOAuth(env, tokens); + + let businessId: string | undefined; + try { + const oauthClient = new HitPayClient({ + environment: env, + auth: { method: 'oauth', accessToken: tokens.access_token }, + }); + const info = await oauthClient.get<{ id?: string }>('/v1/info'); + businessId = info.id; + if (businessId) { + tokens.business_id = businessId; + setProfileOAuth(env, tokens); + } + } catch { + // Non-fatal — tokens are stored + } + + return { environment: env, businessId }; +} + +export function parseLoginEnvironment(value: string): Environment { + if (!isEnvironment(value)) { + throw new Error(`Environment must be one of: ${ENVIRONMENT_NAMES.join(', ')}`); + } + return value; +} diff --git a/src/lib/auth/pkce.ts b/src/lib/auth/pkce.ts new file mode 100644 index 0000000..d864439 --- /dev/null +++ b/src/lib/auth/pkce.ts @@ -0,0 +1,16 @@ +import { createHash, randomBytes } from 'node:crypto'; + +export interface PkcePair { + verifier: string; + challenge: string; +} + +export function generatePkce(): PkcePair { + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + return { verifier, challenge }; +} + +export function generateOAuthState(): string { + return randomBytes(16).toString('hex'); +} diff --git a/src/lib/auth/token-manager.ts b/src/lib/auth/token-manager.ts new file mode 100644 index 0000000..c2f3757 --- /dev/null +++ b/src/lib/auth/token-manager.ts @@ -0,0 +1,120 @@ +import { + type Environment, + type OAuthCredentials, + getOAuthClientId, + getOAuthTokenUrl, + hasOAuthClientId, +} from '../hitpay/environments.js'; +import { + type HitPayConfig, + getProfile, + readConfig, + setProfileOAuth, +} from '../config.js'; +import { environmentFetch } from '../http-fetch.js'; + +interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in?: number; + token_type?: string; +} + +const REFRESH_BUFFER_SECONDS = 300; + +export function isOAuthExpired(oauth: OAuthCredentials): boolean { + return oauth.expires_at <= Math.floor(Date.now() / 1000) + REFRESH_BUFFER_SECONDS; +} + +async function exchangeToken(env: Environment, body: URLSearchParams): Promise { + const tokenUrl = getOAuthTokenUrl(env); + const res = await environmentFetch(env, tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`OAuth token exchange failed (${res.status}): ${text}`); + } + + const data = (await res.json()) as TokenResponse; + const expiresIn = data.expires_in ?? 31_536_000; + + return { + access_token: data.access_token, + refresh_token: data.refresh_token ?? body.get('refresh_token') ?? '', + expires_at: Math.floor(Date.now() / 1000) + expiresIn, + token_type: 'Bearer', + }; +} + +export async function refreshOAuthToken( + env: Environment, + oauth: OAuthCredentials, +): Promise { + const clientId = getOAuthClientId(env); + if (!hasOAuthClientId(env)) { + throw new Error(`OAuth is not configured for environment "${env}".`); + } + + const body = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: oauth.refresh_token, + }); + + return exchangeToken(env, body); +} + +export async function ensureValidOAuthToken( + env: Environment, + config: HitPayConfig = readConfig(), +): Promise<{ accessToken: string; config: HitPayConfig }> { + const profile = getProfile(config, env); + const oauth = profile.oauth; + + if (!oauth?.access_token) { + throw new Error( + `Not authenticated for ${env}. Run \`hitpay login\` or \`hitpay config set api_key\`.`, + ); + } + + if (!isOAuthExpired(oauth)) { + return { accessToken: oauth.access_token, config }; + } + + const refreshed = await refreshOAuthToken(env, oauth); + setProfileOAuth(env, refreshed); + const updated = readConfig(); + return { accessToken: refreshed.access_token, config: updated }; +} + +export async function exchangeAuthorizationCode( + env: Environment, + code: string, + redirectUri: string, + codeVerifier: string, +): Promise { + if (!hasOAuthClientId(env)) { + throw new Error( + `OAuth client ID is not configured for "${env}". Register the HitPay CLI OAuth app first.`, + ); + } + + const clientId = getOAuthClientId(env); + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + redirect_uri: redirectUri, + code, + code_verifier: codeVerifier, + }); + + return exchangeToken(env, body); +} diff --git a/src/lib/client.ts b/src/lib/client.ts index 9efe46b..2312e22 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -1,17 +1,57 @@ +import type { Command } from 'commander'; import { HitPayClient } from './hitpay/client.js'; -import type { Environment } from './hitpay/client.js'; -import { readConfig } from './config.js'; +import { + getAuthMethod, + getProfile, + readConfig, + resolveEnvironment, +} from './config.js'; +import { ensureValidOAuthToken } from './auth/token-manager.js'; +import { getGlobalOpts } from './global-options.js'; +import { getApiBaseUrl } from './hitpay/environments.js'; -export function createClient(opts?: { apiKey?: string; environment?: string }): HitPayClient { +const AUTH_HINT = 'Run `hitpay login` or `hitpay config set api_key `.'; + +export async function createClient(opts?: { + apiKey?: string; + environment?: string; +}): Promise { const config = readConfig(); + const env = resolveEnvironment(config, opts?.environment); + const profile = getProfile(config, env); + + const apiKey = opts?.apiKey ?? profile.api_key; + if (apiKey) { + return new HitPayClient({ + environment: env, + auth: { method: 'api_key', apiKey }, + }); + } - const apiKey = opts?.apiKey || config.api_key; - if (!apiKey) { - throw new Error( - 'No API key found. Run `hitpay login` or set HITPAY_API_KEY environment variable.', - ); + if (profile.oauth?.access_token) { + const { accessToken } = await ensureValidOAuthToken(env, config); + return new HitPayClient({ + environment: env, + auth: { method: 'oauth', accessToken }, + }); } - const env = (opts?.environment || config.environment || 'sandbox') as Environment; - return new HitPayClient(apiKey, env); + const authHint = getAuthMethod(profile); + throw new Error( + authHint ? `Unable to authenticate for ${env}.` : `Not authenticated for ${env}. ${AUTH_HINT}`, + ); +} + +export async function createClientFromCmd(cmd: Command): Promise { + const opts = getGlobalOpts(cmd); + return createClient({ + environment: opts.env, + apiKey: opts.apiKey, + }); +} + +export function getResolvedApiUrl(environment?: string): string { + const config = readConfig(); + const env = resolveEnvironment(config, environment); + return getApiBaseUrl(env); } diff --git a/src/lib/config.ts b/src/lib/config.ts index 870d3e8..1a01f32 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,19 +1,35 @@ -import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; +import { + type Environment, + type EnvironmentProfile, + type OAuthCredentials, + ENVIRONMENT_NAMES, + isEnvironment, +} from './hitpay/environments.js'; const CONFIG_DIR = join(homedir(), '.hitpay'); const CONFIG_FILE = join(CONFIG_DIR, 'config.json'); +export type { Environment, EnvironmentProfile, OAuthCredentials }; + export interface HitPayConfig { - api_key?: string; - salt?: string; - environment?: 'sandbox' | 'production'; + environment?: Environment; currency?: string; country?: string; + profiles?: Partial>; + /** @deprecated Migrated into profiles on read */ + api_key?: string; + /** @deprecated Migrated into profiles on read */ + salt?: string; } -const VALID_KEYS: (keyof HitPayConfig)[] = ['api_key', 'salt', 'environment', 'currency', 'country']; +const GLOBAL_KEYS = ['environment', 'currency', 'country'] as const; +const PROFILE_KEYS = ['api_key', 'salt'] as const; + +export type GlobalConfigKey = (typeof GLOBAL_KEYS)[number]; +export type ProfileConfigKey = (typeof PROFILE_KEYS)[number]; function ensureDir(): void { if (!existsSync(CONFIG_DIR)) { @@ -21,13 +37,50 @@ function ensureDir(): void { } } +/** Migrate legacy flat config (root api_key) into per-environment profiles. */ +export function migrateConfig(raw: HitPayConfig): HitPayConfig { + if (raw.profiles) { + return raw; + } + + const env = raw.environment ?? 'production'; + const profile: EnvironmentProfile = {}; + + if (raw.api_key) profile.api_key = raw.api_key; + if (raw.salt) profile.salt = raw.salt; + + const migrated: HitPayConfig = { + environment: raw.environment, + currency: raw.currency, + country: raw.country, + profiles: Object.keys(profile).length > 0 ? { [env]: profile } : {}, + }; + + return migrated; +} + +/** Remove deprecated profile fields from stored config. */ +function stripDeprecatedProfileFields(config: HitPayConfig): HitPayConfig { + if (!config.profiles) return config; + + for (const env of ENVIRONMENT_NAMES) { + const profile = config.profiles[env]; + if (profile && 'api_url' in profile) { + delete (profile as { api_url?: string }).api_url; + } + } + + return config; +} + export function readConfig(): HitPayConfig { if (!existsSync(CONFIG_FILE)) { return {}; } try { const raw = readFileSync(CONFIG_FILE, 'utf-8'); - return JSON.parse(raw) as HitPayConfig; + const parsed = JSON.parse(raw) as HitPayConfig; + return stripDeprecatedProfileFields(migrateConfig(parsed)); } catch { return {}; } @@ -35,24 +88,127 @@ export function readConfig(): HitPayConfig { export function writeConfig(config: HitPayConfig): void { ensureDir(); - writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }); + const clean = { ...config }; + delete clean.api_key; + delete clean.salt; + writeFileSync(CONFIG_FILE, JSON.stringify(clean, null, 2) + '\n', { mode: 0o600 }); } -export function setConfigValue(key: string, value: string): void { - if (!VALID_KEYS.includes(key as keyof HitPayConfig)) { - throw new Error(`Invalid config key: ${key}. Valid keys: ${VALID_KEYS.join(', ')}`); +export function resolveEnvironment(config: HitPayConfig, override?: string): Environment { + const candidate = override ?? config.environment ?? 'production'; + if (!isEnvironment(candidate)) { + throw new Error( + `Invalid environment: ${candidate}. Valid values: ${ENVIRONMENT_NAMES.join(', ')}`, + ); + } + return candidate; +} + +export function getProfile(config: HitPayConfig, env: Environment): EnvironmentProfile { + return config.profiles?.[env] ?? {}; +} + +export function getActiveEnvironment(config: HitPayConfig): Environment { + return resolveEnvironment(config); +} + +export function ensureProfile(config: HitPayConfig, env: Environment): HitPayConfig { + const next = { ...config, profiles: { ...config.profiles } }; + if (!next.profiles![env]) { + next.profiles![env] = {}; + } + return next; +} + +export function setGlobalValue(key: GlobalConfigKey, value: string): void { + if (!GLOBAL_KEYS.includes(key)) { + throw new Error(`Invalid config key: ${key}`); } - if (key === 'environment' && value !== 'sandbox' && value !== 'production') { - throw new Error('Environment must be "sandbox" or "production"'); + if (key === 'environment' && !isEnvironment(value)) { + throw new Error(`Environment must be one of: ${ENVIRONMENT_NAMES.join(', ')}`); } + + const normalized = + key === 'currency' || key === 'country' ? value.toUpperCase() : value; + const config = readConfig(); - (config as Record)[key] = value; + (config as Record)[key] = normalized; + writeConfig(config); +} + +export function setProfileValue(env: Environment, key: ProfileConfigKey, value: string): void { + if (!PROFILE_KEYS.includes(key)) { + throw new Error(`Invalid profile key: ${key}`); + } + + let config = readConfig(); + config = ensureProfile(config, env); + config.profiles![env]![key] = value; writeConfig(config); } -export function getConfigValue(key: string): string | undefined { +export function setProfileOAuth(env: Environment, oauth: OAuthCredentials): void { + let config = readConfig(); + config = ensureProfile(config, env); + config.profiles![env]!.oauth = oauth; + if (!config.environment) { + config.environment = env; + } + writeConfig(config); +} + +export function unsetProfileValue(env: Environment, key: ProfileConfigKey | 'oauth'): void { + let config = readConfig(); + const profile = config.profiles?.[env]; + if (!profile) return; + + delete profile[key]; + if (Object.keys(profile).length === 0) { + delete config.profiles![env]; + } + writeConfig(config); +} + +export function clearProfileAuth(env: Environment, all = false): void { + let config = readConfig(); + const profile = config.profiles?.[env]; + if (!profile) return; + + delete profile.oauth; + if (all) { + delete profile.api_key; + delete profile.salt; + } + + if (Object.keys(profile).length === 0) { + delete config.profiles![env]; + } + writeConfig(config); +} + +export function getConfigValue(key: string, env?: Environment): string | undefined { const config = readConfig(); - return (config as Record)[key]; + if (GLOBAL_KEYS.includes(key as GlobalConfigKey)) { + return (config as Record)[key]; + } + if (PROFILE_KEYS.includes(key as ProfileConfigKey)) { + const targetEnv = env ?? getActiveEnvironment(config); + return getProfile(config, targetEnv)[key as ProfileConfigKey]; + } + return undefined; +} + +export function getAuthMethod(profile: EnvironmentProfile): 'api_key' | 'oauth' | null { + if (profile.api_key) return 'api_key'; + if (profile.oauth?.access_token) return 'oauth'; + return null; +} + +export function maskSecret(value: string, visibleStart = 8, visibleEnd = 4): string { + if (value.length <= visibleStart + visibleEnd) { + return '*'.repeat(value.length); + } + return value.slice(0, visibleStart) + '...' + value.slice(-visibleEnd); } export function clearConfig(): void { @@ -62,3 +218,23 @@ export function clearConfig(): void { export function getConfigPath(): string { return CONFIG_FILE; } + +/** @deprecated Use setGlobalValue / setProfileValue */ +export function setConfigValue(key: string, value: string): void { + if (GLOBAL_KEYS.includes(key as GlobalConfigKey)) { + setGlobalValue(key as GlobalConfigKey, value); + return; + } + if (PROFILE_KEYS.includes(key as ProfileConfigKey)) { + const config = readConfig(); + setProfileValue(getActiveEnvironment(config), key as ProfileConfigKey, value); + return; + } + throw new Error( + `Invalid config key: ${key}. Valid keys: ${[...GLOBAL_KEYS, ...PROFILE_KEYS].join(', ')}`, + ); +} + +export function migrateConfigForTest(raw: HitPayConfig): HitPayConfig { + return migrateConfig(raw); +} diff --git a/src/lib/global-options.ts b/src/lib/global-options.ts new file mode 100644 index 0000000..dcba423 --- /dev/null +++ b/src/lib/global-options.ts @@ -0,0 +1,19 @@ +import type { Command } from 'commander'; + +export interface GlobalCliOptions { + json?: boolean; + env?: string; + apiKey?: string; +} + +export function getRootCommand(cmd: Command): Command { + let root = cmd; + while (root.parent) { + root = root.parent; + } + return root; +} + +export function getGlobalOpts(cmd: Command): GlobalCliOptions { + return getRootCommand(cmd).opts() as GlobalCliOptions; +} diff --git a/src/lib/hitpay/client.ts b/src/lib/hitpay/client.ts index 6c51094..19c573a 100644 --- a/src/lib/hitpay/client.ts +++ b/src/lib/hitpay/client.ts @@ -1,24 +1,34 @@ // Vendored from hitpay-mcp (currently unpublished) — only the surface the CLI uses. // ponytail: generic REST client + the HitPayApiError contract pinned by tests/lib/errors.test.ts. -export type Environment = 'sandbox' | 'production'; +import { + type Environment, + getApiBaseUrl, +} from './environments.js'; +import { environmentFetch } from '../http-fetch.js'; -const BASE_URLS: Record = { - sandbox: 'https://api.sandbox.hit-pay.com', - production: 'https://api.hit-pay.com', -}; +export type { Environment, EnvironmentProfile, OAuthCredentials } from './environments.js'; + +export type AuthMethod = 'api_key' | 'oauth'; + +export interface HitPayClientOptions { + environment: Environment; + auth: { method: 'api_key'; apiKey: string } | { method: 'oauth'; accessToken: string }; +} interface HitPayErrorBody { message?: string; errors?: Record; } -function suggest(statusCode: number, path: string): string { +function suggest(statusCode: number, path: string, authMethod: AuthMethod): string { switch (statusCode) { case 400: return `Bad request to ${path} — check the parameters.`; case 401: - return 'Authentication failed — check your API key and environment (run `hitpay login`).'; + return authMethod === 'oauth' + ? 'Authentication failed — run `hitpay login` to re-authenticate.' + : 'Authentication failed — check your API key and environment (run `hitpay config set api_key`).'; case 403: return `Not permitted to access ${path}.`; case 404: @@ -44,26 +54,42 @@ export class HitPayApiError extends Error { readonly details?: Record; readonly path: string; - constructor(statusCode: number, body: HitPayErrorBody, path: string) { + constructor(statusCode: number, body: HitPayErrorBody, path: string, authMethod: AuthMethod = 'api_key') { super(body.message ?? `Request to ${path} failed (${statusCode})`); this.name = 'HitPayApiError'; this.statusCode = statusCode; this.errorCode = `HITPAY_${statusCode}`; this.path = path; this.details = body.errors; - this.suggestion = suggest(statusCode, path); + this.suggestion = suggest(statusCode, path, authMethod); } } export class HitPayClient { - private readonly apiKey: string; private readonly baseURL: string; + private readonly auth: HitPayClientOptions['auth']; readonly environment: Environment; + readonly authMethod: AuthMethod; + + constructor(options: HitPayClientOptions) { + this.environment = options.environment; + this.baseURL = getApiBaseUrl(options.environment); + this.auth = options.auth; + this.authMethod = options.auth.method; + } + + /** @deprecated Use HitPayClientOptions constructor. Kept for config verify during set api_key. */ + static withApiKey(apiKey: string, environment: Environment): HitPayClient { + return new HitPayClient({ + environment, + auth: { method: 'api_key', apiKey }, + }); + } - constructor(apiKey: string, environment: Environment = 'sandbox') { - this.apiKey = apiKey; - this.environment = environment; - this.baseURL = BASE_URLS[environment] ?? BASE_URLS.sandbox; + updateOAuthAccessToken(accessToken: string): void { + if (this.auth.method === 'oauth') { + this.auth.accessToken = accessToken; + } } get(path: string, query?: Record): Promise { @@ -82,16 +108,36 @@ export class HitPayClient { return this.request('DELETE', path); } - /** Lightweight auth/ reachability check. */ + /** Lightweight auth / reachability check. */ async verifyConnection(): Promise { + const path = this.auth.method === 'oauth' ? '/v1/info' : '/v1/account-status'; try { - await this.get('/v1/account-status'); + await this.get(path); return true; } catch { return false; } } + private authHeaders(hasBody: boolean): Record { + const headers: Record = { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }; + + if (this.auth.method === 'api_key') { + headers['X-BUSINESS-API-KEY'] = this.auth.apiKey; + } else { + headers.Authorization = `Bearer ${this.auth.accessToken}`; + } + + if (hasBody) { + headers['Content-Type'] = 'application/json'; + } + + return headers; + } + private async request( method: string, path: string, @@ -108,13 +154,9 @@ export class HitPayClient { } const hasBody = body !== undefined; - const res = await fetch(url, { + const res = await environmentFetch(this.environment, url, { method, - headers: { - 'X-BUSINESS-API-KEY': this.apiKey, - Accept: 'application/json', - ...(hasBody ? { 'Content-Type': 'application/json' } : {}), - }, + headers: this.authHeaders(hasBody), body: hasBody ? JSON.stringify(body) : undefined, }); @@ -125,7 +167,7 @@ export class HitPayClient { } catch { // non-JSON error body; proceed with what we have } - throw new HitPayApiError(res.status, errorBody, path); + throw new HitPayApiError(res.status, errorBody, path, this.authMethod); } if (res.status === 204) return undefined as T; diff --git a/src/lib/hitpay/environments.ts b/src/lib/hitpay/environments.ts new file mode 100644 index 0000000..635ae8e --- /dev/null +++ b/src/lib/hitpay/environments.ts @@ -0,0 +1,95 @@ +export type Environment = 'local' | 'staging' | 'sandbox' | 'production'; + +export const ENVIRONMENT_NAMES: Environment[] = ['local', 'staging', 'sandbox', 'production']; + +/** Environments shown in public CLI help (excludes internal local/staging). */ +export const PUBLIC_ENVIRONMENT_NAMES: Environment[] = ['sandbox', 'production']; + +export interface EnvironmentProfile { + api_key?: string; + salt?: string; + oauth?: OAuthCredentials; +} + +export interface OAuthCredentials { + access_token: string; + refresh_token: string; + expires_at: number; + token_type: 'Bearer'; + scopes?: string[]; + business_id?: string; +} + +interface EnvironmentDefinition { + apiBaseUrl: string; + dashboardBaseUrl: string; + oauthAuthorizePath: string; + oauthTokenPath: string; +} + +/** + * First-party HitPay CLI OAuth app client IDs — compiled into the build (one app per environment). + * Replace with the IDs registered in each HitPay OAuth app before publish. + */ +/** Scopes requested during CLI OAuth login (must match scopes enabled on the OAuth app). */ +export const OAUTH_LOGIN_SCOPE = + 'business:read payments:read payments:create payments:cancel payments:refund'; + +export const OAUTH_CLIENT_IDS: Record = { + local: '019f5cb1-5d7f-710c-ac45-dcc448f95ae5', + staging: 'hitpay-cli-staging', + sandbox: 'hitpay-cli-sandbox', + production: 'hitpay-cli-production', +}; + +export const ENVIRONMENTS: Record = { + local: { + apiBaseUrl: 'https://api.src.test', + dashboardBaseUrl: 'https://dashboard.src.test', + oauthAuthorizePath: '/oauth/authorize', + oauthTokenPath: '/v1/open/oauth/token', + }, + staging: { + apiBaseUrl: 'https://api.staging.hit-pay.com', + dashboardBaseUrl: 'https://dashboard.staging.hit-pay.com', + oauthAuthorizePath: '/oauth/authorize', + oauthTokenPath: '/v1/open/oauth/token', + }, + sandbox: { + apiBaseUrl: 'https://api.sandbox.hit-pay.com', + dashboardBaseUrl: 'https://dashboard.sandbox.hit-pay.com', + oauthAuthorizePath: '/oauth/authorize', + oauthTokenPath: '/v1/open/oauth/token', + }, + production: { + apiBaseUrl: 'https://api.hit-pay.com', + dashboardBaseUrl: 'https://dashboard.hit-pay.com', + oauthAuthorizePath: '/oauth/authorize', + oauthTokenPath: '/v1/open/oauth/token', + }, +}; + +export function isEnvironment(value: string): value is Environment { + return (ENVIRONMENT_NAMES as string[]).includes(value); +} + +export function getApiBaseUrl(env: Environment): string { + return ENVIRONMENTS[env].apiBaseUrl; +} + +export function getOAuthAuthorizeUrl(env: Environment): string { + const { dashboardBaseUrl, oauthAuthorizePath } = ENVIRONMENTS[env]; + return `${dashboardBaseUrl}${oauthAuthorizePath}`; +} + +export function getOAuthTokenUrl(env: Environment): string { + return `${getApiBaseUrl(env)}${ENVIRONMENTS[env].oauthTokenPath}`; +} + +export function getOAuthClientId(env: Environment): string { + return OAUTH_CLIENT_IDS[env]; +} + +export function hasOAuthClientId(env: Environment): boolean { + return OAUTH_CLIENT_IDS[env].length > 0; +} diff --git a/src/lib/http-fetch.ts b/src/lib/http-fetch.ts new file mode 100644 index 0000000..265d9ef --- /dev/null +++ b/src/lib/http-fetch.ts @@ -0,0 +1,51 @@ +import { type Environment } from './hitpay/environments.js'; + +function formatFetchError(err: unknown, env: Environment, url: string): Error { + if (err instanceof TypeError && err.message === 'fetch failed') { + const cause = err.cause instanceof Error ? err.cause.message : String(err.cause ?? ''); + const tlsHint = + env === 'local' + ? ' Local HTTPS (api.src.test) uses a dev certificate — the CLI trusts it for `local` only.' + : ''; + return new Error(`Could not reach ${url}: ${cause || 'network error'}.${tlsHint}`); + } + + if (err instanceof Error) { + return err; + } + + return new Error(String(err)); +} + +async function fetchWithOptionalLocalTls(env: Environment, input: string | URL, init?: RequestInit): Promise { + if (env !== 'local') { + return fetch(input, init); + } + + // Local stacks (Valet/Herd/mkcert) often use a CA browsers trust but Node rejects. + const previous = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + try { + return await fetch(input, init); + } finally { + if (previous === undefined) { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + } else { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = previous; + } + } +} + +export async function environmentFetch( + env: Environment, + input: string | URL, + init?: RequestInit, +): Promise { + const url = typeof input === 'string' ? input : input.toString(); + + try { + return await fetchWithOptionalLocalTls(env, input, init); + } catch (err) { + throw formatFetchError(err, env, url); + } +} diff --git a/src/lib/verify-api-key.ts b/src/lib/verify-api-key.ts new file mode 100644 index 0000000..b449ec8 --- /dev/null +++ b/src/lib/verify-api-key.ts @@ -0,0 +1,17 @@ +import { HitPayClient } from './hitpay/client.js'; +import type { Environment } from './hitpay/environments.js'; +import { setProfileValue } from './config.js'; +import { createSpinner } from './spinner.js'; + +export async function verifyAndSaveApiKey(env: Environment, apiKey: string): Promise { + const spinner = createSpinner('Verifying API key...'); + spinner.start(); + const client = HitPayClient.withApiKey(apiKey.trim(), env); + const valid = await client.verifyConnection(); + if (!valid) { + spinner.fail('Invalid API key or unable to connect'); + process.exit(1); + } + spinner.stop(); + setProfileValue(env, 'api_key', apiKey.trim()); +} diff --git a/tests/lib/client.test.ts b/tests/lib/client.test.ts new file mode 100644 index 0000000..b1db5ba --- /dev/null +++ b/tests/lib/client.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const configFsMock = vi.hoisted(() => ({ + fileContent: null as string | null, +})); + +vi.mock('node:fs', () => ({ + existsSync: () => configFsMock.fileContent !== null, + readFileSync: () => configFsMock.fileContent ?? '', + writeFileSync: (_path: string, data: string) => { + configFsMock.fileContent = data; + }, + mkdirSync: vi.fn(), + chmodSync: vi.fn(), +})); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => '/mock/hitpay-home', + }; +}); + +import { HitPayClient } from '../../src/lib/hitpay/client.js'; +import { createClient } from '../../src/lib/client.js'; +import { writeConfig } from '../../src/lib/config.js'; +import * as tokenManager from '../../src/lib/auth/token-manager.js'; + +describe('HitPayClient auth headers', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => '{"status":"ok"}', + }); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('sends X-BUSINESS-API-KEY for api_key auth', async () => { + const client = HitPayClient.withApiKey('sk-test-key', 'sandbox'); + await client.get('/v1/account-status'); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.headers).toMatchObject({ + 'X-BUSINESS-API-KEY': 'sk-test-key', + 'X-Requested-With': 'XMLHttpRequest', + }); + expect(init.headers).not.toHaveProperty('Authorization'); + }); + + it('sends Authorization Bearer for oauth auth', async () => { + const client = new HitPayClient({ + environment: 'sandbox', + auth: { method: 'oauth', accessToken: 'oauth-token-xyz' }, + }); + await client.get('/v1/info'); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.headers).toMatchObject({ + Authorization: 'Bearer oauth-token-xyz', + }); + expect(init.headers).not.toHaveProperty('X-BUSINESS-API-KEY'); + }); + + it('uses /v1/info for oauth verifyConnection', async () => { + const client = new HitPayClient({ + environment: 'sandbox', + auth: { method: 'oauth', accessToken: 'oauth-token-xyz' }, + }); + await client.verifyConnection(); + + const [url] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect(String(url)).toBe('https://api.sandbox.hit-pay.com/v1/info'); + }); + + it('uses /v1/account-status for api_key verifyConnection', async () => { + const client = HitPayClient.withApiKey('sk-test', 'sandbox'); + await client.verifyConnection(); + + const [url] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect(String(url)).toBe('https://api.sandbox.hit-pay.com/v1/account-status'); + }); +}); + +describe('createClient', () => { + beforeEach(() => { + configFsMock.fileContent = null; + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('prefers api_key over oauth in the same profile', async () => { + writeConfig({ + environment: 'sandbox', + profiles: { + sandbox: { + api_key: 'sk-priority', + oauth: { + access_token: 'oauth-token', + refresh_token: 'refresh', + expires_at: 9999999999, + token_type: 'Bearer', + }, + }, + }, + }); + + const client = await createClient(); + expect(client.authMethod).toBe('api_key'); + }); + + it('uses oauth when api_key is absent', async () => { + writeConfig({ + environment: 'sandbox', + profiles: { + sandbox: { + oauth: { + access_token: 'oauth-token', + refresh_token: 'refresh', + expires_at: 9999999999, + token_type: 'Bearer', + }, + }, + }, + }); + + vi.spyOn(tokenManager, 'ensureValidOAuthToken').mockResolvedValue({ + accessToken: 'oauth-token', + config: {}, + }); + + const client = await createClient(); + expect(client.authMethod).toBe('oauth'); + }); + + it('throws when profile has no credentials', async () => { + writeConfig({ environment: 'sandbox', profiles: { sandbox: {} } }); + + await expect(createClient()).rejects.toThrow(/Not authenticated for sandbox/); + }); + + it('honors --env override without changing stored active environment', async () => { + writeConfig({ + environment: 'sandbox', + profiles: { + sandbox: { api_key: 'sk-sandbox' }, + production: { api_key: 'sk-live' }, + }, + }); + + const client = await createClient({ environment: 'production' }); + expect(client.environment).toBe('production'); + expect(client.authMethod).toBe('api_key'); + }); +}); diff --git a/tests/lib/config-command.test.ts b/tests/lib/config-command.test.ts new file mode 100644 index 0000000..61ed178 --- /dev/null +++ b/tests/lib/config-command.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Command } from 'commander'; + +const configFsMock = vi.hoisted(() => ({ + fileContent: null as string | null, +})); + +vi.mock('node:fs', () => ({ + existsSync: () => configFsMock.fileContent !== null, + readFileSync: () => configFsMock.fileContent ?? '', + writeFileSync: (_path: string, data: string) => { + configFsMock.fileContent = data; + }, + mkdirSync: vi.fn(), + chmodSync: vi.fn(), +})); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => '/mock/hitpay-home', + }; +}); + +import { registerConfig } from '../../src/commands/config.js'; +import * as output from '../../src/lib/output.js'; +import { + getConfigValue, + readConfig, + setGlobalValue, + setProfileValue, + unsetProfileValue, + writeConfig, +} from '../../src/lib/config.js'; +import { verifyAndSaveApiKey } from '../../src/lib/verify-api-key.js'; +import { HitPayClient } from '../../src/lib/hitpay/client.js'; + +function createConfigProgram(): Command { + const program = new Command(); + registerConfig(program); + return program; +} + +describe('config command helpers', () => { + beforeEach(() => { + configFsMock.fileContent = null; + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('setGlobalValue handles currency and country (config set)', () => { + writeConfig({ environment: 'sandbox', profiles: { sandbox: { api_key: 'sk-x' } } }); + setGlobalValue('currency', 'SGD'); + setGlobalValue('country', 'sg'); + + const config = readConfig(); + expect(config.currency).toBe('SGD'); + expect(config.country).toBe('SG'); + expect(config.profiles?.sandbox?.api_key).toBe('sk-x'); + }); + + it('verifyAndSaveApiKey stores key after verification (config set api_key)', async () => { + vi.spyOn(HitPayClient.prototype, 'verifyConnection').mockResolvedValue(true); + + await verifyAndSaveApiKey('sandbox', 'sk-sandbox-key'); + + expect(readConfig().profiles?.sandbox?.api_key).toBe('sk-sandbox-key'); + }); + + it('setProfileValue stores profile keys (config set salt)', () => { + writeConfig({ environment: 'local', profiles: {} }); + setProfileValue('local', 'salt', 'webhook-salt'); + + const profile = readConfig().profiles?.local; + expect(profile?.salt).toBe('webhook-salt'); + }); + + it('unsetProfileValue clears profile keys (config unset)', () => { + writeConfig({ + environment: 'sandbox', + profiles: { sandbox: { api_key: 'sk-x', salt: 'webhook-salt' } }, + }); + + unsetProfileValue('sandbox', 'salt'); + + const profile = readConfig().profiles?.sandbox; + expect(profile?.salt).toBeUndefined(); + expect(profile?.api_key).toBe('sk-x'); + }); + + it('getConfigValue reads global and profile values (config get)', () => { + writeConfig({ + environment: 'production', + currency: 'SGD', + profiles: { production: { api_key: 'sk-live' } }, + }); + + expect(getConfigValue('environment')).toBe('production'); + expect(getConfigValue('currency')).toBe('SGD'); + expect(getConfigValue('api_key', 'production')).toBe('sk-live'); + }); +}); + +describe('config command', () => { + let successSpy: ReturnType; + let errorSpy: ReturnType; + let exitSpy: ReturnType; + let logSpy: ReturnType; + + beforeEach(() => { + configFsMock.fileContent = null; + successSpy = vi.spyOn(output, 'success').mockImplementation(() => {}); + errorSpy = vi.spyOn(output, 'error').mockImplementation(() => {}); + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('rejects config set environment and directs to hitpay env use', async () => { + await createConfigProgram().parseAsync([ + 'node', + 'hitpay', + 'config', + 'set', + 'environment', + 'sandbox', + ]); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Switch environment with `hitpay env use `'), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(readConfig().environment).toBeUndefined(); + }); + + it('config set currency normalizes to uppercase', async () => { + writeConfig({ environment: 'production', profiles: {} }); + + await createConfigProgram().parseAsync([ + 'node', + 'hitpay', + 'config', + 'set', + 'currency', + 'sgd', + ]); + + expect(readConfig().currency).toBe('SGD'); + expect(successSpy).toHaveBeenCalledWith('Set currency = SGD'); + }); + + it('config get environment prints active environment', async () => { + writeConfig({ environment: 'sandbox', profiles: {} }); + + await createConfigProgram().parseAsync(['node', 'hitpay', 'config', 'get', 'environment']); + + expect(logSpy).toHaveBeenCalledWith('sandbox'); + }); + + it('config get environment defaults to production when unset', async () => { + await createConfigProgram().parseAsync(['node', 'hitpay', 'config', 'get', 'environment']); + + expect(logSpy).toHaveBeenCalledWith('production'); + }); +}); diff --git a/tests/lib/config-store.test.ts b/tests/lib/config-store.test.ts new file mode 100644 index 0000000..0e42203 --- /dev/null +++ b/tests/lib/config-store.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const configFsMock = vi.hoisted(() => ({ + fileContent: null as string | null, +})); + +vi.mock('node:fs', () => ({ + existsSync: () => configFsMock.fileContent !== null, + readFileSync: () => configFsMock.fileContent ?? '', + writeFileSync: (_path: string, data: string) => { + configFsMock.fileContent = data; + }, + mkdirSync: vi.fn(), + chmodSync: vi.fn(), +})); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => '/mock/hitpay-home', + }; +}); + +import { + readConfig, + writeConfig, + setGlobalValue, + setProfileValue, + setProfileOAuth, + unsetProfileValue, + clearProfileAuth, + getConfigValue, + resolveEnvironment, + maskSecret, + migrateConfigForTest, +} from '../../src/lib/config.js'; + +function loadStoredConfig() { + if (!configFsMock.fileContent) return {}; + return JSON.parse(configFsMock.fileContent); +} + +describe('Config file store', () => { + beforeEach(() => { + configFsMock.fileContent = null; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns empty config when file is missing', () => { + expect(readConfig()).toEqual({}); + }); + + it('migrates legacy flat config on read', () => { + configFsMock.fileContent = JSON.stringify({ + environment: 'sandbox', + api_key: 'sk-legacy', + }); + + const config = readConfig(); + expect(config.profiles?.sandbox?.api_key).toBe('sk-legacy'); + expect(config.api_key).toBeUndefined(); + }); + + it('setProfileValue stores credentials under active environment profile', () => { + writeConfig({ environment: 'sandbox', profiles: {} }); + setProfileValue('sandbox', 'api_key', 'sk-sandbox'); + + const stored = loadStoredConfig(); + expect(stored.profiles.sandbox.api_key).toBe('sk-sandbox'); + }); + + it('keeps separate profiles when switching environments', () => { + writeConfig({ + environment: 'sandbox', + profiles: { + sandbox: { api_key: 'sk-sandbox' }, + production: { api_key: 'sk-live' }, + }, + }); + + setGlobalValue('environment', 'production'); + + const stored = loadStoredConfig(); + expect(stored.environment).toBe('production'); + expect(stored.profiles.sandbox.api_key).toBe('sk-sandbox'); + expect(stored.profiles.production.api_key).toBe('sk-live'); + }); + + it('setProfileOAuth sets tokens and defaults active environment', () => { + setProfileOAuth('staging', { + access_token: 'access-1', + refresh_token: 'refresh-1', + expires_at: 9999999999, + token_type: 'Bearer', + }); + + const stored = loadStoredConfig(); + expect(stored.environment).toBe('staging'); + expect(stored.profiles.staging.oauth.access_token).toBe('access-1'); + }); + + it('unsetProfileValue removes a profile field', () => { + writeConfig({ + environment: 'sandbox', + profiles: { sandbox: { api_key: 'sk-x', salt: 's' } }, + }); + + unsetProfileValue('sandbox', 'api_key'); + + const stored = loadStoredConfig(); + expect(stored.profiles.sandbox.api_key).toBeUndefined(); + expect(stored.profiles.sandbox.salt).toBe('s'); + }); + + it('clearProfileAuth removes oauth and optionally api_key', () => { + writeConfig({ + environment: 'sandbox', + profiles: { + sandbox: { + api_key: 'sk-x', + salt: 's', + oauth: { + access_token: 'a', + refresh_token: 'r', + expires_at: 1, + token_type: 'Bearer', + }, + }, + }, + }); + + clearProfileAuth('sandbox', false); + let stored = loadStoredConfig(); + expect(stored.profiles.sandbox.oauth).toBeUndefined(); + expect(stored.profiles.sandbox.api_key).toBe('sk-x'); + + clearProfileAuth('sandbox', true); + stored = loadStoredConfig(); + expect(stored.profiles.sandbox).toBeUndefined(); + }); + + it('writeConfig strips deprecated root api_key and salt', () => { + writeConfig({ + environment: 'sandbox', + api_key: 'legacy', + salt: 'legacy-salt', + profiles: { sandbox: { api_key: 'sk-new' } }, + }); + + const stored = loadStoredConfig(); + expect(stored.api_key).toBeUndefined(); + expect(stored.salt).toBeUndefined(); + }); + + it('getConfigValue reads global and profile keys', () => { + writeConfig({ + environment: 'production', + currency: 'SGD', + profiles: { + production: { api_key: 'sk-live' }, + }, + }); + + expect(getConfigValue('environment')).toBe('production'); + expect(getConfigValue('currency')).toBe('SGD'); + expect(getConfigValue('api_key')).toBe('sk-live'); + expect(getConfigValue('api_key', 'production')).toBe('sk-live'); + }); + + it('resolveEnvironment uses override, config, then production default', () => { + expect(resolveEnvironment({}, undefined)).toBe('production'); + expect(resolveEnvironment({ environment: 'production' }, undefined)).toBe('production'); + expect(resolveEnvironment({ environment: 'production' }, 'staging')).toBe('staging'); + }); + + it('resolveEnvironment rejects invalid values', () => { + expect(() => resolveEnvironment({}, 'invalid')).toThrow(/Invalid environment/); + }); + + it('maskSecret hides middle of long values', () => { + expect(maskSecret('sk-sandbox-abcdefghijklmnop')).toBe('sk-sandb...mnop'); + expect(maskSecret('short')).toBe('*****'); + }); +}); + +describe('migrateConfigForTest', () => { + it('creates empty profiles object when no legacy credentials exist', () => { + expect(migrateConfigForTest({ environment: 'sandbox' })).toEqual({ + environment: 'sandbox', + profiles: {}, + }); + }); +}); diff --git a/tests/lib/config.test.ts b/tests/lib/config.test.ts index 0d9aa06..b2b6695 100644 --- a/tests/lib/config.test.ts +++ b/tests/lib/config.test.ts @@ -1,57 +1,129 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -// We need to mock the config path before importing -const TEST_DIR = join(tmpdir(), `hitpay-cli-test-${Date.now()}`); -const TEST_CONFIG = join(TEST_DIR, 'config.json'); - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os'); - return { - ...actual, - homedir: () => tmpdir() + `/hitpay-cli-test-home-${Date.now()}`, - }; +import { describe, it, expect } from 'vitest'; +import { + migrateConfigForTest, + getAuthMethod, + getProfile, + maskSecret, + resolveEnvironment, +} from '../../src/lib/config.js'; +import { + ENVIRONMENT_NAMES, + getApiBaseUrl, + getOAuthAuthorizeUrl, + getOAuthTokenUrl, + getOAuthClientId, + hasOAuthClientId, + isEnvironment, + OAUTH_LOGIN_SCOPE, +} from '../../src/lib/hitpay/environments.js'; + +describe('Config migration (pure)', () => { + it('migrates flat api_key into profiles for active environment', () => { + const migrated = migrateConfigForTest({ + environment: 'sandbox', + api_key: 'sk-test-123', + salt: 'salt-abc', + }); + + expect(migrated.profiles?.sandbox?.api_key).toBe('sk-test-123'); + expect(migrated.profiles?.sandbox?.salt).toBe('salt-abc'); + }); + + it('leaves profile-based config unchanged', () => { + const input = { + environment: 'production' as const, + profiles: { + production: { api_key: 'sk-live' }, + sandbox: { + oauth: { + access_token: 't', + refresh_token: 'r', + expires_at: 999, + token_type: 'Bearer' as const, + }, + }, + }, + }; + expect(migrateConfigForTest(input)).toEqual(input); + }); + + it('resolves auth method with api_key priority', () => { + expect( + getAuthMethod({ + api_key: 'sk-xxx', + oauth: { access_token: 't', refresh_token: 'r', expires_at: 999, token_type: 'Bearer' }, + }), + ).toBe('api_key'); + expect( + getAuthMethod({ + oauth: { access_token: 't', refresh_token: 'r', expires_at: 999, token_type: 'Bearer' }, + }), + ).toBe('oauth'); + expect(getAuthMethod({})).toBeNull(); + }); }); -// Import after mock setup — but since the config module uses homedir at module level, -// we'll test the core logic directly -describe('Config module', () => { - beforeEach(() => { - mkdirSync(TEST_DIR, { recursive: true }); +describe('Environments (pure)', () => { + it('recognizes all planned environments', () => { + for (const env of ENVIRONMENT_NAMES) { + expect(isEnvironment(env)).toBe(true); + } + expect(isEnvironment('invalid')).toBe(false); }); - afterEach(() => { - rmSync(TEST_DIR, { recursive: true, force: true }); + it('uses hardcoded default API URLs', () => { + expect(getApiBaseUrl('local')).toBe('https://api.src.test'); + expect(getApiBaseUrl('staging')).toBe('https://api.staging.hit-pay.com'); + expect(getApiBaseUrl('sandbox')).toBe('https://api.sandbox.hit-pay.com'); + expect(getApiBaseUrl('production')).toBe('https://api.hit-pay.com'); }); - it('reads empty config when file does not exist', () => { - const nonExistent = join(TEST_DIR, 'nope.json'); - expect(existsSync(nonExistent)).toBe(false); + it('builds OAuth URLs from environment definitions', () => { + expect(getOAuthAuthorizeUrl('sandbox')).toBe( + 'https://dashboard.sandbox.hit-pay.com/oauth/authorize', + ); + expect(getOAuthTokenUrl('staging')).toBe( + 'https://api.staging.hit-pay.com/v1/open/oauth/token', + ); + expect(getOAuthTokenUrl('local')).toBe('https://api.src.test/v1/open/oauth/token'); }); - it('writes and reads config as JSON', () => { - const config = { api_key: 'sk-test-123', environment: 'sandbox' as const }; - writeFileSync(TEST_CONFIG, JSON.stringify(config, null, 2)); + it('includes hardcoded oauth client ids for every environment', () => { + for (const env of ENVIRONMENT_NAMES) { + expect(getOAuthClientId(env)).toBeTruthy(); + expect(hasOAuthClientId(env)).toBe(true); + } + expect(getOAuthClientId('sandbox')).toBe('hitpay-cli-sandbox'); + expect(getOAuthClientId('production')).toBe('hitpay-cli-production'); + }); - const raw = readFileSync(TEST_CONFIG, 'utf-8'); - const parsed = JSON.parse(raw); - expect(parsed.api_key).toBe('sk-test-123'); - expect(parsed.environment).toBe('sandbox'); + it('requests business and payment scopes during oauth login', () => { + expect(OAUTH_LOGIN_SCOPE).toContain('business:read'); + expect(OAUTH_LOGIN_SCOPE).toContain('payments:create'); + expect(OAUTH_LOGIN_SCOPE).toContain('payments:read'); }); +}); - it('config file can store all valid keys', () => { - const config = { - api_key: 'sk-test-456', - salt: 'salt-123', - environment: 'production' as const, - currency: 'SGD', - country: 'SG', - }; - writeFileSync(TEST_CONFIG, JSON.stringify(config)); - const raw = readFileSync(TEST_CONFIG, 'utf-8'); - const parsed = JSON.parse(raw); - expect(parsed).toEqual(config); +describe('Profile lookup (pure)', () => { + it('returns empty profile when env not configured', () => { + expect(getProfile({ environment: 'sandbox' }, 'sandbox')).toEqual({}); + }); +}); + +describe('resolveEnvironment (pure)', () => { + it('defaults to production and accepts overrides', () => { + expect(resolveEnvironment({})).toBe('production'); + expect(resolveEnvironment({ environment: 'production' }, 'staging')).toBe('staging'); + }); + + it('rejects invalid environment names', () => { + expect(() => resolveEnvironment({}, 'not-real')).toThrow(/Invalid environment/); + }); +}); + +describe('maskSecret (pure)', () => { + it('masks long secrets', () => { + expect(maskSecret('abcdefghijklmnop')).toBe('abcdefgh...mnop'); + expect(maskSecret('tiny')).toBe('****'); }); }); diff --git a/tests/lib/env.test.ts b/tests/lib/env.test.ts new file mode 100644 index 0000000..8caa2d2 --- /dev/null +++ b/tests/lib/env.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Command } from 'commander'; + +const configFsMock = vi.hoisted(() => ({ + fileContent: null as string | null, +})); + +vi.mock('node:fs', () => ({ + existsSync: () => configFsMock.fileContent !== null, + readFileSync: () => configFsMock.fileContent ?? '', + writeFileSync: (_path: string, data: string) => { + configFsMock.fileContent = data; + }, + mkdirSync: vi.fn(), + chmodSync: vi.fn(), +})); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => '/mock/hitpay-home', + }; +}); + +import { registerEnv } from '../../src/commands/env.js'; +import * as output from '../../src/lib/output.js'; +import { + getActiveEnvironment, + readConfig, + setGlobalValue, + writeConfig, +} from '../../src/lib/config.js'; + +function createEnvProgram(): Command { + const program = new Command(); + registerEnv(program); + return program; +} + +describe('hitpay env command', () => { + let printDataSpy: ReturnType; + let successSpy: ReturnType; + let errorSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + configFsMock.fileContent = null; + printDataSpy = vi.spyOn(output, 'printData').mockImplementation(() => {}); + successSpy = vi.spyOn(output, 'success').mockImplementation(() => {}); + errorSpy = vi.spyOn(output, 'error').mockImplementation(() => {}); + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows active environment when called without subcommand', async () => { + writeConfig({ environment: 'staging' }); + + await createEnvProgram().parseAsync(['node', 'hitpay', 'env']); + + expect(printDataSpy).toHaveBeenCalledWith( + expect.objectContaining({ + active_environment: 'staging', + config_path: expect.stringContaining('config.json'), + }), + ); + }); + + it('defaults to production when config has no environment', async () => { + await createEnvProgram().parseAsync(['node', 'hitpay', 'env']); + + expect(printDataSpy).toHaveBeenCalledWith( + expect.objectContaining({ active_environment: 'production' }), + ); + }); + + it('switches active environment via env use and preserves other profiles', async () => { + writeConfig({ + environment: 'production', + profiles: { + production: { api_key: 'sk-live' }, + sandbox: { api_key: 'sk-sandbox' }, + }, + }); + + await createEnvProgram().parseAsync(['node', 'hitpay', 'env', 'use', 'sandbox']); + + expect(readConfig().environment).toBe('sandbox'); + expect(readConfig().profiles?.production?.api_key).toBe('sk-live'); + expect(readConfig().profiles?.sandbox?.api_key).toBe('sk-sandbox'); + expect(successSpy).toHaveBeenCalledWith('Active environment set to sandbox'); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('accepts all supported environment names', async () => { + for (const env of ['local', 'staging', 'sandbox', 'production'] as const) { + configFsMock.fileContent = null; + await createEnvProgram().parseAsync(['node', 'hitpay', 'env', 'use', env]); + expect(readConfig().environment).toBe(env); + } + }); + + it('rejects invalid environment names', async () => { + await createEnvProgram().parseAsync(['node', 'hitpay', 'env', 'use', 'qa']); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Environment must be one of')); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +describe('hitpay env helpers', () => { + beforeEach(() => { + configFsMock.fileContent = null; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('setGlobalValue switches active environment', () => { + writeConfig({ environment: 'production', profiles: { production: { api_key: 'sk-live' } } }); + setGlobalValue('environment', 'sandbox'); + + expect(readConfig().environment).toBe('sandbox'); + expect(readConfig().profiles?.production?.api_key).toBe('sk-live'); + }); + + it('defaults to production when environment is unset', () => { + expect(getActiveEnvironment({})).toBe('production'); + }); +}); diff --git a/tests/lib/errors.test.ts b/tests/lib/errors.test.ts index 43d32d2..3abfc21 100644 --- a/tests/lib/errors.test.ts +++ b/tests/lib/errors.test.ts @@ -10,6 +10,11 @@ describe('HitPayApiError', () => { expect(err.suggestion).toContain('API key'); }); + it('suggests OAuth re-login for 401 when using bearer auth', () => { + const err = new HitPayApiError(401, { message: 'Unauthorized' }, '/v1/test', 'oauth'); + expect(err.suggestion).toContain('hitpay login'); + }); + it('includes validation details', () => { const err = new HitPayApiError( 422, diff --git a/tests/lib/help.test.ts b/tests/lib/help.test.ts new file mode 100644 index 0000000..255feee --- /dev/null +++ b/tests/lib/help.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Command } from 'commander'; +import { registerHelp } from '../../src/commands/help.js'; +import { registerEnv } from '../../src/commands/env.js'; +import { registerConfig } from '../../src/commands/config.js'; +import { registerLogin, registerLogout, registerWhoami } from '../../src/commands/login.js'; + +function createHelpProgram(): Command { + const program = new Command(); + program.name('hitpay').description('HitPay CLI').version('0.1.0'); + registerEnv(program); + registerLogin(program); + registerLogout(program); + registerWhoami(program); + registerConfig(program); + registerHelp(program); + return program; +} + +describe('hitpay help', () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows expanded auth and config details in overview', async () => { + await createHelpProgram().parseAsync(['node', 'hitpay', 'help']); + + const output = logSpy.mock.calls.map((call) => call.join(' ')).join('\n'); + + expect(output).toContain('Authentication & configuration'); + expect(output).toContain('hitpay env'); + expect(output).toContain('hitpay env use '); + expect(output).toContain('hitpay config set '); + expect(output).toContain('api_key, salt'); + expect(output).toContain('hitpay env use ` to switch'); + }); +}); diff --git a/tests/lib/login-command.test.ts b/tests/lib/login-command.test.ts new file mode 100644 index 0000000..c206f83 --- /dev/null +++ b/tests/lib/login-command.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Command } from 'commander'; + +const configFsMock = vi.hoisted(() => ({ + fileContent: null as string | null, +})); + +vi.mock('node:fs', () => ({ + existsSync: () => configFsMock.fileContent !== null, + readFileSync: () => configFsMock.fileContent ?? '', + writeFileSync: (_path: string, data: string) => { + configFsMock.fileContent = data; + }, + mkdirSync: vi.fn(), + chmodSync: vi.fn(), +})); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => '/mock/hitpay-home', + }; +}); + +import { registerLogin } from '../../src/commands/login.js'; +import * as output from '../../src/lib/output.js'; + +function createLoginProgram(): Command { + const program = new Command(); + program.option('--env ', 'One-off profile override'); + registerLogin(program); + return program; +} + +describe('hitpay login command', () => { + let errorSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + configFsMock.fileContent = null; + errorSpy = vi.spyOn(output, 'error').mockImplementation(() => {}); + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('rejects --env and directs to hitpay env use', async () => { + await createLoginProgram().parseAsync(['node', 'hitpay', '--env', 'sandbox', 'login']); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Switch environment first with `hitpay env use `'), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/tests/lib/oauth.test.ts b/tests/lib/oauth.test.ts new file mode 100644 index 0000000..df5d891 --- /dev/null +++ b/tests/lib/oauth.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { parseLoginEnvironment } from '../../src/lib/auth/oauth.js'; + +describe('parseLoginEnvironment', () => { + it('accepts all supported environments', () => { + expect(parseLoginEnvironment('local')).toBe('local'); + expect(parseLoginEnvironment('staging')).toBe('staging'); + expect(parseLoginEnvironment('sandbox')).toBe('sandbox'); + expect(parseLoginEnvironment('production')).toBe('production'); + }); + + it('rejects unknown environments', () => { + expect(() => parseLoginEnvironment('qa')).toThrow(/Environment must be one of/); + }); +}); diff --git a/tests/lib/pkce.test.ts b/tests/lib/pkce.test.ts new file mode 100644 index 0000000..770dc20 --- /dev/null +++ b/tests/lib/pkce.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { createHash } from 'node:crypto'; +import { generateOAuthState, generatePkce } from '../../src/lib/auth/pkce.js'; + +describe('PKCE helpers', () => { + it('generates verifier/challenge pair where challenge is S256 of verifier', () => { + const { verifier, challenge } = generatePkce(); + + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); + expect(challenge).toBe(createHash('sha256').update(verifier).digest('base64url')); + }); + + it('generates unique PKCE pairs', () => { + const a = generatePkce(); + const b = generatePkce(); + expect(a.verifier).not.toBe(b.verifier); + expect(a.challenge).not.toBe(b.challenge); + }); + + it('generates hex OAuth state', () => { + const state = generateOAuthState(); + expect(state).toMatch(/^[0-9a-f]{32}$/); + expect(generateOAuthState()).not.toBe(state); + }); +}); diff --git a/tests/lib/token-manager.test.ts b/tests/lib/token-manager.test.ts new file mode 100644 index 0000000..c79c110 --- /dev/null +++ b/tests/lib/token-manager.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + isOAuthExpired, + ensureValidOAuthToken, + refreshOAuthToken, +} from '../../src/lib/auth/token-manager.js'; +import * as environments from '../../src/lib/hitpay/environments.js'; + +describe('token-manager', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('isOAuthExpired', () => { + it('returns true when token expires within refresh buffer', () => { + const now = Math.floor(Date.now() / 1000); + expect( + isOAuthExpired({ + access_token: 'a', + refresh_token: 'r', + expires_at: now + 100, + token_type: 'Bearer', + }), + ).toBe(true); + }); + + it('returns false when token expires outside refresh buffer', () => { + const now = Math.floor(Date.now() / 1000); + expect( + isOAuthExpired({ + access_token: 'a', + refresh_token: 'r', + expires_at: now + 3600, + token_type: 'Bearer', + }), + ).toBe(false); + }); + }); + + describe('ensureValidOAuthToken', () => { + it('returns existing access token when not expired', async () => { + const now = Math.floor(Date.now() / 1000); + const config = { + environment: 'sandbox' as const, + profiles: { + sandbox: { + oauth: { + access_token: 'valid-token', + refresh_token: 'refresh', + expires_at: now + 3600, + token_type: 'Bearer' as const, + }, + }, + }, + }; + + const result = await ensureValidOAuthToken('sandbox', config); + expect(result.accessToken).toBe('valid-token'); + }); + + it('throws when oauth tokens are missing', async () => { + await expect( + ensureValidOAuthToken('sandbox', { environment: 'sandbox', profiles: { sandbox: {} } }), + ).rejects.toThrow(/Not authenticated for sandbox/); + }); + }); + + describe('refreshOAuthToken', () => { + it('throws when oauth client id is missing', async () => { + vi.spyOn(environments, 'hasOAuthClientId').mockReturnValue(false); + + await expect( + refreshOAuthToken('sandbox', { + access_token: 'a', + refresh_token: 'r', + expires_at: 1, + token_type: 'Bearer', + }), + ).rejects.toThrow(/OAuth is not configured/); + }); + + it('posts refresh_token grant to token endpoint', async () => { + vi.spyOn(environments, 'getOAuthClientId').mockReturnValue('cli-client-id'); + vi.spyOn(environments, 'hasOAuthClientId').mockReturnValue(true); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: 'new-access', + refresh_token: 'new-refresh', + expires_in: 3600, + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const tokens = await refreshOAuthToken('sandbox', { + access_token: 'old', + refresh_token: 'old-refresh', + expires_at: 1, + token_type: 'Bearer', + }); + + expect(tokens.access_token).toBe('new-access'); + expect(tokens.refresh_token).toBe('new-refresh'); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.sandbox.hit-pay.com/v1/open/oauth/token'); + expect(init.method).toBe('POST'); + expect(String(init.body)).toContain('grant_type=refresh_token'); + expect(String(init.body)).toContain('client_id=cli-client-id'); + + vi.unstubAllGlobals(); + }); + }); +}); From 164d9090dd3d4e19447c448c9ec56e4c1b63c34a Mon Sep 17 00:00:00 2001 From: Ari Bahtiar Date: Mon, 20 Jul 2026 19:31:39 +0700 Subject: [PATCH 2/5] Fix OAuth login hang and improve the browser success page. Release stdin after spinner completion and shut down the callback server cleanly so the CLI exits without Ctrl+C, while showing a styled success page with a 10-second auto-close countdown. Co-authored-by: Cursor --- src/commands/login.ts | 8 +- src/lib/auth/oauth.ts | 232 +++++++++++++++++++++++++++++++++++++----- src/lib/spinner.ts | 97 ++++++++++++++++-- 3 files changed, 299 insertions(+), 38 deletions(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index e50444d..d028d96 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -34,12 +34,18 @@ export function registerLogin(program: Command): void { const config = readConfig(); const env = getActiveEnvironment(config); - const spinner = createSpinner('Opening browser for sign-in...'); + const spinner = createSpinner('Opening browser for sign-in...', { discardStdin: false }); spinner.start(); await loginWithOAuth({ environment: env, port: Number(opts.oauthPort), + onWaitingForAuth: () => { + spinner.text = 'Waiting for authorization in browser...'; + }, + onCompleting: () => { + spinner.text = 'Completing sign-in...'; + }, }); spinner.succeed(`Authenticated with HitPay (${env})`); diff --git a/src/lib/auth/oauth.ts b/src/lib/auth/oauth.ts index b3dca7c..672e64b 100644 --- a/src/lib/auth/oauth.ts +++ b/src/lib/auth/oauth.ts @@ -1,4 +1,4 @@ -import { createServer, type Server } from 'node:http'; +import { createServer, type Server, type ServerResponse } from 'node:http'; import { URL } from 'node:url'; import { type Environment, @@ -18,9 +18,169 @@ import { generateOAuthState, generatePkce } from './pkce.js'; const DEFAULT_OAUTH_PORT = 8085; const OAUTH_CALLBACK_PATH = '/callback'; +function oauthSuccessPageHtml(): string { + return ` + + + + + HitPay CLI — Signed in + + + +
+
HitPay CLI
+ +

You're signed in

+

Authentication completed successfully. You can close this tab and return to your terminal.

+

+ This tab will close automatically in + 10s. +
+ If it stays open, close it manually — your CLI is already authenticated. +

+
+ + +`; +} + +function endResponse(res: ServerResponse, status: number, body: string, type = 'text/plain'): void { + res.writeHead(status, { + 'Content-Type': type, + Connection: 'close', + }); + res.end(body); + res.socket?.destroy(); +} + +function closeOAuthServer(server: Server | undefined): Promise { + if (!server) return Promise.resolve(); + + return new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }); +} + export interface OAuthLoginOptions { environment: Environment; port?: number; + onWaitingForAuth?: () => void; + onCompleting?: () => void; } export interface OAuthLoginResult { @@ -53,33 +213,44 @@ function buildAuthorizeUrl( function waitForCallback(port: number, expectedState: string): Promise<{ code: string }> { return new Promise((resolve, reject) => { let server: Server | undefined; + let settled = false; + + const finish = (handler: () => void) => { + if (settled) return; + settled = true; + process.off('SIGINT', onSigint); + void closeOAuthServer(server).finally(handler); + server = undefined; + }; - const cleanup = () => { - server?.close(); + const onSigint = () => { + finish(() => { + reject(new Error('Login cancelled.')); + }); }; + process.on('SIGINT', onSigint); + server = createServer((req, res) => { try { if (!req.url) { - res.writeHead(400); - res.end('Bad request'); + endResponse(res, 400, 'Bad request'); return; } const incoming = new URL(req.url, `http://127.0.0.1:${port}`); if (incoming.pathname !== OAUTH_CALLBACK_PATH) { - res.writeHead(404); - res.end('Not found'); + endResponse(res, 404, 'Not found'); return; } const error = incoming.searchParams.get('error'); if (error) { - res.writeHead(400, { 'Content-Type': 'text/plain' }); - res.end(`Authorization denied: ${error}`); - cleanup(); - reject(new Error(`OAuth authorization denied: ${error}`)); + endResponse(res, 400, `Authorization denied: ${error}`); + finish(() => { + reject(new Error(`OAuth authorization denied: ${error}`)); + }); return; } @@ -87,34 +258,37 @@ function waitForCallback(port: number, expectedState: string): Promise<{ code: s const code = incoming.searchParams.get('code'); if (!state || state !== expectedState) { - res.writeHead(400, { 'Content-Type': 'text/plain' }); - res.end('Invalid OAuth state'); - cleanup(); - reject(new Error('OAuth state mismatch — possible CSRF attempt.')); + endResponse(res, 400, 'Invalid OAuth state'); + finish(() => { + reject(new Error('OAuth state mismatch — possible CSRF attempt.')); + }); return; } if (!code) { - res.writeHead(400, { 'Content-Type': 'text/plain' }); - res.end('Missing authorization code'); - cleanup(); - reject(new Error('OAuth callback missing authorization code.')); + endResponse(res, 400, 'Missing authorization code'); + finish(() => { + reject(new Error('OAuth callback missing authorization code.')); + }); return; } - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end( - '

HitPay CLI

Authenticated. You can close this window.

', - ); - cleanup(); - resolve({ code }); + endResponse(res, 200, oauthSuccessPageHtml(), 'text/html; charset=utf-8'); + finish(() => { + resolve({ code }); + }); } catch (err) { - cleanup(); - reject(err); + finish(() => { + reject(err); + }); } }); - server.on('error', reject); + server.on('error', (err) => { + finish(() => { + reject(err); + }); + }); server.listen(port, '127.0.0.1'); }); } @@ -137,7 +311,9 @@ export async function loginWithOAuth(options: OAuthLoginOptions): Promise Date: Mon, 20 Jul 2026 19:36:06 +0700 Subject: [PATCH 3/5] Redirect OAuth success page to the environment dashboard. Replace the unreliable tab auto-close with a 3-second redirect to the matching HitPay dashboard, plus a manual continue link. Co-authored-by: Cursor --- src/lib/auth/oauth.ts | 46 ++++++++++++++++++++++++++-------- src/lib/hitpay/environments.ts | 4 +++ tests/lib/config.test.ts | 8 ++++++ 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/lib/auth/oauth.ts b/src/lib/auth/oauth.ts index 672e64b..9ef24e4 100644 --- a/src/lib/auth/oauth.ts +++ b/src/lib/auth/oauth.ts @@ -3,6 +3,7 @@ import { URL } from 'node:url'; import { type Environment, ENVIRONMENT_NAMES, + getDashboardBaseUrl, getOAuthAuthorizeUrl, getOAuthClientId, hasOAuthClientId, @@ -17,13 +18,21 @@ import { generateOAuthState, generatePkce } from './pkce.js'; const DEFAULT_OAUTH_PORT = 8085; const OAUTH_CALLBACK_PATH = '/callback'; +const OAUTH_SUCCESS_REDIRECT_SECONDS = 3; + +function escapeHtmlAttribute(value: string): string { + return value.replaceAll('&', '&').replaceAll('"', '"'); +} + +function oauthSuccessPageHtml(dashboardUrl: string): string { + const safeDashboardUrl = escapeHtmlAttribute(dashboardUrl); -function oauthSuccessPageHtml(): string { return ` + HitPay CLI — Signed in