diff --git a/README.md b/README.md index ce1c0ae..2a5f9ca 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @formo/cli -Command-line interface for the Formo API. Query wallet profiles and run analytics SQL queries directly from your terminal or via AI agents. +Command-line interface for the Formo API. Manage wallet profiles, alerts, dashboards, charts, contracts, segments, and run analytics SQL — directly from your terminal or via AI agents. ## Installation @@ -12,7 +12,7 @@ npx @formo/cli ## Authentication -Authenticate by saving your API key: +Save your API key locally: ```bash formo login @@ -24,86 +24,315 @@ Or set the `FORMO_API_KEY` environment variable — it takes precedence over the export FORMO_API_KEY=formo_abc123 ``` -## Commands +Get your API key from `Settings → API Keys` in the [Formo dashboard](https://app.formo.so). -### `formo login` +--- -Save your API key to `~/.config/formo/config.json`. +## Auth commands -| Argument | Description | -|---|---| -| `apiKey` | Your `formo_` API key | +### `formo login [apiKey]` + +Save your API key to `~/.config/formo/config.json`. Validates the key against the API and stores the workspace context. ```bash formo login formo_abc123 ``` +### `formo logout` + +Remove the saved API key and clear authentication state. + +```bash +formo logout +``` + +### `formo status` + +Show current authentication state, workspace, and project ID. + +```bash +formo status +``` + --- -### `formo profiles get` +## `formo profiles` -Fetch a single wallet profile by address or ENS name. +Wallet profile commands. -| Argument | Description | -|---|---| -| `address` | Wallet address (`0x…`) or ENS name | +### `profiles get
` + +Fetch a single wallet profile by address or ENS name. | Option | Description | |---|---| -| `--expand` | Comma-separated fields to expand: `apps`, `chains`, `tokens`, `labels` | +| `--expand` | Comma-separated fields: `apps`, `chains`, `tokens`, `labels` | ```bash formo profiles get 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 formo profiles get vitalik.eth --expand labels,chains ``` ---- - -### `formo profiles search` +### `profiles search` -Search wallet profiles with optional filters and sorting. +Search wallet profiles with filters, sorting, and pagination. Returns a `PaginatedResponse`. | Option | Description | |---|---| | `--address` | Filter by wallet address | -| `--limit` | Max results to return | -| `--offset` | Pagination offset | -| `--orderBy` | Field to sort by (see values below) | -| `--orderDir` | Sort direction: `asc` or `desc` | +| `--page` | Page number (1-indexed, default `1`) | +| `--size` | Page size (default `100`, max `1000`) | +| `--orderBy` | `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` | +| `--orderDir` | `asc` or `desc` | | `--expand` | Comma-separated fields to expand | | `--conditions` | JSON array of `FilterCondition` objects (see below) | +| `--logic` | Combine conditions with `and` (default) or `or` | + +```bash +formo profiles search --size 10 +formo profiles search --orderBy net_worth_usd --orderDir desc --size 5 +formo profiles search --page 2 --size 20 +formo profiles search --conditions '[{"field":"net_worth_usd","op":"gt","value":10000}]' --size 20 +formo profiles search --conditions '[{"field":"net_worth_usd","op":"gt","value":10000},{"field":"tx_count","op":"gt","value":50}]' --logic or --size 20 +``` + +### `profiles update
` + +Merge-update identity properties on a wallet profile. + +| Option | Description | +|---|---| +| `--properties` | JSON object of properties to merge | + +**Allowed property keys:** `user_id`, `display_name`, `email`, `farcaster`, `discord`, `twitter`, `telegram`, `instagram`, `website`, `github`, `linkedin`, `facebook`, `tiktok`, `youtube`, `reddit`, `avatar`, `description`, `location`, `ens`, `lens`, `basenames`, `linea`. Unknown keys are rejected server-side. + +```bash +formo profiles update 0xd8dA... --properties '{"display_name":"Vitalik","twitter":"VitalikButerin"}' +formo profiles update vitalik.eth --properties '{"email":"alice@example.com"}' +``` + +> Requires `profiles:write` scope. + +### `profiles labels create
` + +Upsert one or more labels on a wallet profile. Provide either a single label via `--tagId` or a batch via `--labels`. -**`--orderBy` values:** `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` +| Option | Description | +|---|---| +| `--tagId` | Label identifier (e.g. `vip`, `airdrop_eligible`) | +| `--value` | Optional label value (e.g. tier name, country code) | +| `--chainId` | Optional chain identifier the label applies to | +| `--labels` | JSON array of `UserLabelInput` objects for batch upsert | + +```bash +formo profiles labels create 0xd8dA... --tagId vip +formo profiles labels create 0xd8dA... --tagId tier --value gold --chainId 1 +formo profiles labels create 0xd8dA... --labels '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]' +``` + +### `profiles labels delete
` + +Delete a label from a wallet profile. + +| Option | Description | +|---|---| +| `--tagId` | Label identifier to delete (required) | +| `--chainId` | Optional chain identifier to scope the deletion | + +```bash +formo profiles labels delete 0xd8dA... --tagId vip +formo profiles labels delete 0xd8dA... --tagId tier --chainId 1 +``` + +> Requires `profiles:write` scope. + +--- + +## `formo alerts` + +Project alert commands. Requires `alerts:read` (list/get) or `alerts:write` (create/update/delete/toggle). + +### `alerts list` +List all alerts for the project. + +### `alerts get ` +Get a single alert by ID. + +### `alerts create` + +| Option | Description | +|---|---| +| `--name` | Alert name | +| `--triggerType` | Trigger type (e.g. `event`, `threshold`) | +| `--triggerFilters` | JSON array of trigger filter objects | +| `--recipient` | JSON array of recipient objects | +| `--secret` | Webhook secret | ```bash -# First 10 profiles -formo profiles search --limit 10 +formo alerts create --name "High value tx" --triggerType event \ + --triggerFilters '[{"name":"event","operator":"equals","value":"transaction"}]' \ + --recipient '[{"type":"email","value":["alerts@myapp.com"]}]' +``` + +### `alerts update ` +Same options as `create`. Replaces the alert configuration. -# Top 5 by net worth -formo profiles search --orderBy net_worth_usd --orderDir desc --limit 5 +### `alerts delete ` +Delete an alert. -# Advanced filter with conditions -formo profiles search --conditions '[{"field":"net_worth_usd","op":"gt","value":10000}]' --limit 20 +### `alerts toggle --status ` +Toggle an alert between `active` and `paused`. + +```bash +formo alerts toggle alert_abc123 --status paused ``` --- -### `formo query run` +## `formo boards` + +Dashboard board commands. Requires `boards:read` / `boards:write`. + +### `boards list` +List all boards for the project. -Run a SQL query against your Formo analytics data. +### `boards get ` +Get a single board by ID. + +### `boards create` + +| Option | Description | +|---|---| +| `--name` | Board name | +| `--description` | Optional board description | + +```bash +formo boards create --name "Revenue Metrics" --description "Weekly revenue tracking" +``` + +### `boards update ` + +| Option | Description | +|---|---| +| `--name` | New board name | +| `--description` | New board description | + +### `boards delete ` +Delete a board. + +--- + +## `formo charts` + +Chart commands. Charts live inside a board. Requires `charts:read` / `charts:write`. + +### `charts list --boardId ` +List all charts in a board. + +### `charts get --boardId ` +Get a single chart by ID. + +### `charts create --boardId --body ''` +Create a chart from a JSON config string. + +### `charts update --boardId --body ''` +Update a chart. + +### `charts delete --boardId ` +Delete a chart. + +--- + +## `formo contracts` + +Smart contract commands. Requires `contracts:read` / `contracts:write`. + +### `contracts list` +List all tracked contracts. Returns `{ data: Contract[], deploy: { last_deployed_at, diff }, total, page, size, has_more }`. + +### `contracts create` + +| Option | Description | +|---|---| +| `--address` | Contract address (`0x…`) | +| `--chain` | Chain ID (e.g. `1`, `137`) | +| `--name` | Human-readable contract name | +| `--abi` | Contract ABI as a JSON string | +| `--events` | Events configuration as a JSON string | + +```bash +formo contracts create --address 0x1f9840a85d5af5bf1d1762f925bdaddc4201f984 --chain 1 \ + --name "UNI Token" --abi '[{"type":"event","name":"Transfer","inputs":[]}]' \ + --events '{"Transfer":true}' +``` + +### `contracts update
` + +| Option | Description | +|---|---| +| `--name` | Updated contract name | +| `--abi` | Updated ABI | +| `--events` | Updated events config | + +### `contracts delete
` +Remove a tracked contract. + +--- + +## `formo segments` + +User segment commands. Requires `segments:read` / `segments:write`. + +### `segments list` +List all user segments. + +### `segments create` + +| Option | Description | +|---|---| +| `--title` | Segment title | +| `--filterSets` | JSON array of filter set strings defining the segment | + +### `segments delete ` +Delete a user segment. + +--- + +## `formo query` + +### `query run ""` + +Run a SQL query against your Formo analytics data. Returns `{ data, total, limit, offset, has_more }`. ```bash formo query run "SELECT count(*) FROM events" formo query run "SELECT address, net_worth_usd FROM wallet_profiles ORDER BY net_worth_usd DESC LIMIT 10" ``` -> Requires `query:read` scope on your API key. +> Requires `query:read` scope. + +--- + +## `formo import` + +### `import wallets` + +Bulk-import wallet addresses into the project via the events API. + +| Option | Description | +|---|---| +| `--addresses` | JSON array of wallet address strings | +| `--writeKey` | Project write SDK key | + +```bash +formo import wallets --addresses '["0xabc...","0xdef..."]' --writeKey write_key_xyz +``` --- ## FilterCondition reference -The `--conditions` option accepts a JSON array of filter condition objects: +`profiles search --conditions` accepts a JSON array of filter condition objects: ```json [ @@ -115,10 +344,25 @@ The `--conditions` option accepts a JSON array of filter condition objects: | Field | Type | Description | |---|---|---| | `field` | `string` | Profile field to filter on | -| `op` | `string` | Operator: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin` | +| `op` | `string` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin` | | `value` | `any` | Value to compare against | -Multiple conditions are combined with `AND` logic. +Combine multiple conditions with `--logic and` (default) or `--logic or`. + +--- + +## Output formats + +Every command supports the standard incur output flags: + +| Flag | Description | +|---|---| +| `--format ` | Output format (default: `toon`) | +| `--json` | Shorthand for `--format json` | +| `--verbose` | Include the full envelope (`ok`, `data`, `meta`) | +| `--filter-output ` | Filter output by key paths (e.g. `data,meta.duration`) | + +Every list endpoint returns a `PaginatedResponse` envelope: `{ data: [...], total, page, size, has_more }`. Every error follows: `{ error: { code, message, doc_url, param?, details? } }` — branch on `error.code`, not `message`. --- @@ -129,14 +373,14 @@ Multiple conditions are combined with `AND` logic. pnpm install # Run in development mode -pnpm --filter @formo/cli dev +pnpm dev # Build TypeScript -pnpm --filter @formo/cli build +pnpm build -# Run tests -pnpm --filter @formo/cli test +# Lint +pnpm lint -# Watch tests -pnpm --filter @formo/cli test:watch +# Run tests (requires TEST_TOKEN in .env) +pnpm test ``` diff --git a/src/commands/alerts.ts b/src/commands/alerts.ts index d820409..6742ec0 100644 --- a/src/commands/alerts.ts +++ b/src/commands/alerts.ts @@ -54,10 +54,7 @@ export interface CreateAlertOptions { secret?: string } -export function createAlertRun(options: CreateAlertOptions) { - requireApiKey() - const client = createClient() - +export function buildAlertBody(options: CreateAlertOptions | UpdateAlertOptions) { const body: Record = { name: options.name, trigger_type: options.triggerType, @@ -79,11 +76,17 @@ export function createAlertRun(options: CreateAlertOptions) { } } - if (options.secret) { + if (options.secret !== undefined) { body.secret = options.secret } - return client.post('/v0/alerts/', body) + return body +} + +export function createAlertRun(options: CreateAlertOptions) { + requireApiKey() + const client = createClient() + return client.post('/v0/alerts/', buildAlertBody(options)) } alerts.command('create', { @@ -126,33 +129,10 @@ export interface UpdateAlertOptions { export function updateAlertRun(alertId: string, options: UpdateAlertOptions) { requireApiKey() const client = createClient() - - const body: Record = { - name: options.name, - trigger_type: options.triggerType, - } - - if (options.triggerFilters) { - try { - body.trigger_filters = JSON.parse(options.triggerFilters) - } catch { - throw new Error('--triggerFilters must be a valid JSON array') - } - } - - if (options.recipient) { - try { - body.recipient = JSON.parse(options.recipient) - } catch { - throw new Error('--recipient must be a valid JSON array') - } - } - - if (options.secret !== undefined) { - body.secret = options.secret - } - - return client.put(`/v0/alerts/${encodeURIComponent(alertId)}`, body) + return client.put( + `/v0/alerts/${encodeURIComponent(alertId)}`, + buildAlertBody(options), + ) } alerts.command('update', { diff --git a/src/commands/contracts.ts b/src/commands/contracts.ts index 3bf2291..bd1ae9c 100644 --- a/src/commands/contracts.ts +++ b/src/commands/contracts.ts @@ -32,10 +32,7 @@ export interface CreateContractOptions { events: string } -export function createContractRun(options: CreateContractOptions) { - requireApiKey() - const client = createClient() - +export function buildCreateContractBody(options: CreateContractOptions) { let parsedAbi: unknown try { parsedAbi = JSON.parse(options.abi) @@ -50,13 +47,19 @@ export function createContractRun(options: CreateContractOptions) { throw new Error('--events must be valid JSON') } - return client.post('/v0/contracts/', { + return { address: options.address, chain: options.chain, name: options.name, abi: parsedAbi, events: parsedEvents, - }) + } +} + +export function createContractRun(options: CreateContractOptions) { + requireApiKey() + const client = createClient() + return client.post('/v0/contracts/', buildCreateContractBody(options)) } contracts.command('create', { @@ -94,14 +97,7 @@ export interface UpdateContractOptions { events: string } -export function updateContractRun( - chain: string, - address: string, - options: UpdateContractOptions, -) { - requireApiKey() - const client = createClient() - +export function buildUpdateContractBody(options: UpdateContractOptions) { let parsedAbi: unknown try { parsedAbi = JSON.parse(options.abi) @@ -116,13 +112,23 @@ export function updateContractRun( throw new Error('--events must be valid JSON') } + return { + name: options.name, + abi: parsedAbi, + events: parsedEvents, + } +} + +export function updateContractRun( + chain: string, + address: string, + options: UpdateContractOptions, +) { + requireApiKey() + const client = createClient() return client.put( `/v0/contracts/${encodeURIComponent(chain)}/${encodeURIComponent(address)}`, - { - name: options.name, - abi: parsedAbi, - events: parsedEvents, - }, + buildUpdateContractBody(options), ) } diff --git a/src/commands/import.ts b/src/commands/import.ts index 1b06935..83a6cc8 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -12,10 +12,7 @@ export interface ImportWalletsOptions { writeKey: string } -export function importWalletsRun(options: ImportWalletsOptions) { - requireApiKey() - const client = createClient() - +export function buildImportBody(options: ImportWalletsOptions) { let parsedAddresses: unknown try { parsedAddresses = JSON.parse(options.addresses) @@ -25,11 +22,16 @@ export function importWalletsRun(options: ImportWalletsOptions) { } catch { throw new Error('--addresses must be a valid JSON array of wallet address strings') } - - return client.post('/v0/import/', { + return { addresses: parsedAddresses, writeKey: options.writeKey, - }) + } +} + +export function importWalletsRun(options: ImportWalletsOptions) { + requireApiKey() + const client = createClient() + return client.post('/v0/import/', buildImportBody(options)) } importCmd.command('wallets', { diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index bcc28d4..e2d4e4b 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -39,8 +39,8 @@ profiles.command('get', { export interface SearchProfilesOptions { address?: string - limit?: number - offset?: number + page?: number + size?: number orderBy?: string orderDir?: string expand?: string @@ -54,8 +54,8 @@ export function searchProfilesRun(options: SearchProfilesOptions) { const params: Record = {} if (options.address) params.address = options.address - if (options.limit !== undefined) params.limit = options.limit - if (options.offset !== undefined) params.offset = options.offset + if (options.page !== undefined) params.page = options.page + if (options.size !== undefined) params.size = options.size if (options.orderBy) params.order_by = options.orderBy if (options.orderDir) params.order_dir = options.orderDir if (options.expand) params.expand = options.expand @@ -78,8 +78,8 @@ profiles.command('search', { description: 'Search wallet profiles with optional filters', options: z.object({ address: z.string().optional().describe('Filter by wallet address'), - limit: z.coerce.number().optional().describe('Max results to return'), - offset: z.coerce.number().optional().describe('Pagination offset'), + page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'), + size: z.coerce.number().optional().describe('Page size (default 100, max 1000)'), orderBy: z .enum([ 'last_onchain', @@ -108,15 +108,19 @@ profiles.command('search', { .describe('Logic operator for combining conditions: "and" (default) or "or"'), }), examples: [ - { options: { limit: 10 }, description: 'List first 10 profiles' }, + { options: { size: 10 }, description: 'List first 10 profiles' }, { - options: { orderBy: 'net_worth_usd', orderDir: 'desc', limit: 5 }, + options: { orderBy: 'net_worth_usd', orderDir: 'desc', size: 5 }, description: 'Top 5 profiles by net worth', }, + { + options: { page: 2, size: 20 }, + description: 'Get the second page of 20 profiles', + }, { options: { conditions: '[{"field":"net_worth_usd","op":"gt","value":10000}]', - limit: 20, + size: 20, }, description: 'Search profiles with net worth > 10000', }, @@ -124,7 +128,7 @@ profiles.command('search', { options: { conditions: '[{"field":"net_worth_usd","op":"gt","value":10000},{"field":"tx_count","op":"gt","value":50}]', logic: 'or', - limit: 20, + size: 20, }, description: 'Search profiles matching either condition', }, @@ -133,3 +137,212 @@ profiles.command('search', { return searchProfilesRun(options) }, }) + +// ── Update profile (merge identity properties) ── + +export interface UpdateProfileOptions { + properties: string +} + +export function buildUpdateProfileBody(options: UpdateProfileOptions) { + let body: Record + try { + body = JSON.parse(options.properties) + if (!body || typeof body !== 'object' || Array.isArray(body)) + throw new Error('not an object') + } catch { + throw new Error('--properties must be a JSON object of property keys') + } + if (Object.keys(body).length === 0) { + throw new Error('--properties must contain at least one key') + } + return body +} + +export function updateProfileRun( + address: string, + options: UpdateProfileOptions, +) { + requireApiKey() + const client = createClient() + return client.put( + `/v0/profiles/${encodeURIComponent(address)}/properties`, + buildUpdateProfileBody(options), + ) +} + +profiles.command('update', { + description: 'Merge-update identity properties on a wallet profile', + args: z.object({ + address: z.string().describe('Wallet address (0x... or ENS name)'), + }), + options: z.object({ + properties: z + .string() + .describe( + 'JSON object of properties to merge. Allowed keys: user_id, display_name, email, farcaster, discord, twitter, telegram, instagram, website, github, linkedin, facebook, tiktok, youtube, reddit, avatar, description, location, ens, lens, basenames, linea', + ), + }), + examples: [ + { + args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + options: { + properties: '{"display_name":"Vitalik","twitter":"VitalikButerin"}', + }, + description: 'Set display name and Twitter handle', + }, + { + args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + options: { properties: '{"email":"alice@example.com"}' }, + description: 'Set just the email', + }, + ], + hint: 'Requires profiles:write scope on your API key. Only the listed keys are accepted; unknown keys are rejected.', + run({ args, options }) { + return updateProfileRun(args.address, options) + }, +}) + +// ── Labels sub-resource ── + +export const profilesLabels = Cli.create('labels', { + description: 'Manage labels on a wallet profile', +}) + +// ── Create / upsert profile label(s) ── + +export interface CreateProfileLabelOptions { + tagId?: string + value?: string + chainId?: string + labels?: string +} + +export function buildCreateLabelBody(options: CreateProfileLabelOptions): unknown { + if (options.labels) { + try { + const parsed = JSON.parse(options.labels) + if (!Array.isArray(parsed) || parsed.length === 0) + throw new Error('not a non-empty array') + return parsed + } catch { + throw new Error('--labels must be a non-empty JSON array of UserLabelInput objects') + } + } + if (options.tagId) { + const single: Record = { tag_id: options.tagId } + if (options.value) single.value = options.value + if (options.chainId) single.chain_id = options.chainId + return single + } + throw new Error('Provide --tagId (single label) or --labels (batch JSON array)') +} + +export function createProfileLabelRun( + address: string, + options: CreateProfileLabelOptions, +) { + requireApiKey() + const client = createClient() + return client.post( + `/v0/profiles/${encodeURIComponent(address)}/labels`, + buildCreateLabelBody(options), + ) +} + +profilesLabels.command('create', { + description: 'Upsert one or more labels on a wallet profile', + args: z.object({ + address: z.string().describe('Wallet address (0x... or ENS name)'), + }), + options: z.object({ + tagId: z + .string() + .optional() + .describe('Label identifier (e.g. "vip", "airdrop_eligible")'), + value: z.string().optional().describe('Optional label value (e.g. tier name, country code)'), + chainId: z.string().optional().describe('Optional chain identifier the label applies to'), + labels: z + .string() + .optional() + .describe('JSON array of UserLabelInput objects for batch upsert'), + }), + examples: [ + { + args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + options: { tagId: 'vip' }, + description: 'Tag a wallet as VIP', + }, + { + args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + options: { tagId: 'tier', value: 'gold', chainId: '1' }, + description: 'Apply a tiered label scoped to a chain', + }, + { + args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + options: { labels: '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]' }, + description: 'Apply multiple labels in one call', + }, + ], + hint: 'Requires profiles:write scope on your API key.', + run({ args, options }) { + return createProfileLabelRun(args.address, options) + }, +}) + +// ── Delete a profile label ── + +export interface DeleteProfileLabelOptions { + tagId: string + chainId?: string +} + +export function buildDeleteLabelBody(options: DeleteProfileLabelOptions) { + if (!options.tagId) { + throw new Error('--tagId is required') + } + const body: Record = { tag_id: options.tagId } + if (options.chainId) body.chain_id = options.chainId + return body +} + +export function deleteProfileLabelRun( + address: string, + options: DeleteProfileLabelOptions, +) { + requireApiKey() + const client = createClient() + return client.delete( + `/v0/profiles/${encodeURIComponent(address)}/labels`, + { data: buildDeleteLabelBody(options) }, + ) +} + +profilesLabels.command('delete', { + description: 'Delete a label from a wallet profile', + args: z.object({ + address: z.string().describe('Wallet address (0x... or ENS name)'), + }), + options: z.object({ + tagId: z.string().describe('Label identifier to delete'), + chainId: z.string().optional().describe('Optional chain identifier to scope the deletion'), + }), + examples: [ + { + args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + options: { tagId: 'vip' }, + description: 'Remove the vip label', + }, + { + args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + options: { tagId: 'tier', chainId: '1' }, + description: 'Remove a chain-scoped label', + }, + ], + hint: 'Requires profiles:write scope on your API key.', + run({ args, options }) { + return deleteProfileLabelRun(args.address, options) + }, +}) + +profiles.command(profilesLabels) diff --git a/src/commands/segments.ts b/src/commands/segments.ts index 03d2cc4..3c4672f 100644 --- a/src/commands/segments.ts +++ b/src/commands/segments.ts @@ -29,10 +29,7 @@ export interface CreateSegmentOptions { filterSets: string } -export function createSegmentRun(options: CreateSegmentOptions) { - requireApiKey() - const client = createClient() - +export function buildCreateSegmentBody(options: CreateSegmentOptions) { let parsedFilterSets: unknown try { parsedFilterSets = JSON.parse(options.filterSets) @@ -40,10 +37,16 @@ export function createSegmentRun(options: CreateSegmentOptions) { throw new Error('--filterSets must be a valid JSON array') } - return client.post('/v0/segments/', { + return { title: options.title, filterSets: parsedFilterSets, - }) + } +} + +export function createSegmentRun(options: CreateSegmentOptions) { + requireApiKey() + const client = createClient() + return client.post('/v0/segments/', buildCreateSegmentBody(options)) } segments.command('create', { diff --git a/src/index.ts b/src/index.ts index 37e6265..b3ae255 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,15 +37,11 @@ function loginGuide(): string { ].join('\n'); } -interface ValidateApiKeyResponse { - isSuccess: boolean; - message?: string; - data?: { - validated: boolean; - details: string; - teamId: string; - scopes: { project_id?: string } | null; - }; +interface ValidateApiKeyData { + validated: boolean; + details: string; + teamId: string; + scopes: { project_id?: string } | null; } async function validateAndFetchWorkspace( @@ -60,12 +56,12 @@ async function validateAndFetchWorkspace( if (!res.ok) return null; - const body = (await res.json()) as ValidateApiKeyResponse; - if (!body.isSuccess || !body.data) return null; + const body = (await res.json()) as ValidateApiKeyData; + if (!body.validated) return null; return { - workspace: body.data.details, - projectId: body.data.scopes?.project_id ?? '', + workspace: body.details, + projectId: body.scopes?.project_id ?? '', }; } catch { return null; diff --git a/src/lib/client.ts b/src/lib/client.ts index 451aea0..8bdfb54 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -3,6 +3,52 @@ import { getApiKey } from './config' const BASE_URL = 'https://api.formo.so' +export interface ApiErrorBody { + error?: { + code?: string + message?: string + doc_url?: string + param?: string + details?: Record + } +} + +export interface DecoratedApiError extends Error { + status?: number + code?: string + docUrl?: string + param?: string + details?: Record + transportCode?: string +} + +/** + * Translate an AxiosError into a thrown Error with the API's structured + * `{ error: { code, message, doc_url, param, details } }` envelope decoded + * onto the Error instance and into a multi-line, human-readable `.message`. + * + * Exported for unit testing — used by the response interceptor below. + */ +export function parseApiError(error: AxiosError): DecoratedApiError { + const status = error.response?.status + const body = error.response?.data as ApiErrorBody | undefined + const apiError = body?.error + const baseMessage = apiError?.message ?? error.message + const parts: string[] = [] + parts.push(apiError?.code ? `[${apiError.code}] ${baseMessage}` : baseMessage) + if (apiError?.param) parts.push(`Param: ${apiError.param}`) + if (apiError?.doc_url) parts.push(`Docs: ${apiError.doc_url}`) + const message = parts.join('\n ') + return Object.assign(new Error(message), { + status, + code: apiError?.code, + docUrl: apiError?.doc_url, + param: apiError?.param, + details: apiError?.details, + transportCode: error.code, + }) +} + function createClient() { const apiKey = getApiKey() const baseURL = BASE_URL @@ -19,16 +65,7 @@ function createClient() { instance.interceptors.response.use( (res) => res.data, (error: AxiosError) => { - const status = error.response?.status - const data = error.response?.data as - | { message?: string; error?: string | { message?: string; code?: string } } - | undefined - const errorField = data?.error - const message = - data?.message ?? - (typeof errorField === 'object' ? errorField?.message : errorField) ?? - error.message - throw Object.assign(new Error(message), { status, code: error.code }) + throw parseApiError(error) }, ) diff --git a/test/commands/alerts.test.ts b/test/commands/alerts.test.ts index 9ed0f18..aff5c85 100644 --- a/test/commands/alerts.test.ts +++ b/test/commands/alerts.test.ts @@ -1,17 +1,20 @@ import { expect } from 'chai'; import { listAlertsRun, getAlertRun, createAlertRun } from '../../src/commands/alerts'; +import { requiresLiveApi } from '../helpers/liveApi'; -// Response shape: { isSuccess: true, data: Alert[] } for list -// { isSuccess: true, data: Alert } for get +// Response shape: PaginatedResponse { data, total, page, size, has_more } for list, +// Alert for get (bare resource — no envelope). describe('commands/alerts', function () { let firstAlertId: string | undefined; describe('listAlertsRun()', function () { - it('returns an array of alerts', async function () { - const res = await listAlertsRun() as { isSuccess: boolean; data: { id: string }[] }; - expect(res.isSuccess).to.equal(true); + it('returns a paginated list of alerts', async function () { + await requiresLiveApi(this); + const res = await listAlertsRun() as { data: { id: string }[]; total: number; has_more: boolean }; expect(res.data).to.be.an('array'); + expect(res).to.have.property('total'); + expect(res).to.have.property('has_more'); if (res.data.length > 0) firstAlertId = res.data[0].id; }); }); @@ -19,9 +22,8 @@ describe('commands/alerts', function () { describe('getAlertRun()', function () { it('returns an alert by ID', async function () { if (!firstAlertId) return this.skip(); - const res = await getAlertRun(firstAlertId) as { isSuccess: boolean; data: { id: string } }; - expect(res.isSuccess).to.equal(true); - expect(res.data).to.have.property('id', firstAlertId); + const res = await getAlertRun(firstAlertId) as { id: string }; + expect(res).to.have.property('id', firstAlertId); }); }); diff --git a/test/commands/boards.test.ts b/test/commands/boards.test.ts index 0327311..6e07783 100644 --- a/test/commands/boards.test.ts +++ b/test/commands/boards.test.ts @@ -1,17 +1,20 @@ import { expect } from 'chai'; import { listBoardsRun, getBoardRun } from '../../src/commands/boards'; +import { requiresLiveApi } from '../helpers/liveApi'; -// Response shape: { isSuccess: true, data: Board[] } for list -// { isSuccess: true, data: Board } for get +// Response shape: PaginatedResponse { data, total, page, size, has_more } for list, +// Board for get (bare resource — no envelope). describe('commands/boards', function () { let firstBoardId: string | undefined; describe('listBoardsRun()', function () { - it('returns an array of boards', async function () { - const res = await listBoardsRun() as { isSuccess: boolean; data: { id: string }[] }; - expect(res.isSuccess).to.equal(true); + it('returns a paginated list of boards', async function () { + await requiresLiveApi(this); + const res = await listBoardsRun() as { data: { id: string }[]; total: number; has_more: boolean }; expect(res.data).to.be.an('array'); + expect(res).to.have.property('total'); + expect(res).to.have.property('has_more'); if (res.data.length > 0) firstBoardId = res.data[0].id; }); }); @@ -19,9 +22,8 @@ describe('commands/boards', function () { describe('getBoardRun()', function () { it('returns a board by ID', async function () { if (!firstBoardId) return this.skip(); - const res = await getBoardRun(firstBoardId) as { isSuccess: boolean; data: { id: string } }; - expect(res.isSuccess).to.equal(true); - expect(res.data).to.have.property('id', firstBoardId); + const res = await getBoardRun(firstBoardId) as { id: string }; + expect(res).to.have.property('id', firstBoardId); }); }); }); diff --git a/test/commands/bodyBuilders.test.ts b/test/commands/bodyBuilders.test.ts new file mode 100644 index 0000000..6ed187d --- /dev/null +++ b/test/commands/bodyBuilders.test.ts @@ -0,0 +1,198 @@ +import { expect } from 'chai'; +import { buildAlertBody } from '../../src/commands/alerts'; +import { + buildCreateContractBody, + buildUpdateContractBody, +} from '../../src/commands/contracts'; +import { buildImportBody } from '../../src/commands/import'; +import { + buildCreateLabelBody, + buildDeleteLabelBody, + buildUpdateProfileBody, +} from '../../src/commands/profiles'; +import { buildCreateSegmentBody } from '../../src/commands/segments'; + +describe('commands / body builders', function () { + // ── Alerts ── + + describe('buildAlertBody()', function () { + it('translates camelCase options to snake_case body keys', function () { + const body = buildAlertBody({ name: 'My alert', triggerType: 'event' }); + expect(body).to.deep.equal({ name: 'My alert', trigger_type: 'event' }); + }); + + it('parses triggerFilters JSON into trigger_filters', function () { + const body = buildAlertBody({ + name: 'x', + triggerType: 'event', + triggerFilters: '[{"name":"event","operator":"equals","value":"transaction"}]', + }); + expect(body.trigger_filters).to.deep.equal([ + { name: 'event', operator: 'equals', value: 'transaction' }, + ]); + }); + + it('parses recipient JSON into the body', function () { + const body = buildAlertBody({ + name: 'x', + triggerType: 'event', + recipient: '[{"type":"email","value":["a@b.com"]}]', + }); + expect(body.recipient).to.deep.equal([ + { type: 'email', value: ['a@b.com'] }, + ]); + }); + + it('includes secret only when provided', function () { + const without = buildAlertBody({ name: 'x', triggerType: 'event' }); + expect(without).to.not.have.property('secret'); + const withSecret = buildAlertBody({ + name: 'x', + triggerType: 'event', + secret: 'whsec_123', + }); + expect(withSecret.secret).to.equal('whsec_123'); + }); + + it('includes empty-string secret when explicitly set (allows clearing the value)', function () { + const body = buildAlertBody({ + name: 'x', + triggerType: 'event', + secret: '', + }); + expect(body).to.have.property('secret', ''); + }); + }); + + // ── Contracts ── + + describe('buildCreateContractBody()', function () { + it('parses abi + events JSON and assembles the body verbatim', function () { + const body = buildCreateContractBody({ + address: '0xabc', + chain: 1, + name: 'My Token', + abi: '[{"type":"event","name":"Transfer"}]', + events: '{"Transfer":true}', + }); + expect(body).to.deep.equal({ + address: '0xabc', + chain: 1, + name: 'My Token', + abi: [{ type: 'event', name: 'Transfer' }], + events: { Transfer: true }, + }); + }); + }); + + describe('buildUpdateContractBody()', function () { + it('does NOT include address/chain (those are path params, not body)', function () { + const body = buildUpdateContractBody({ + name: 'New Name', + abi: '[]', + events: '{}', + }); + expect(body).to.deep.equal({ name: 'New Name', abi: [], events: {} }); + expect(body).to.not.have.property('address'); + expect(body).to.not.have.property('chain'); + }); + }); + + // ── Segments ── + + describe('buildCreateSegmentBody()', function () { + it('parses filterSets JSON into the body', function () { + const body = buildCreateSegmentBody({ + title: 'Whales', + filterSets: '["net_worth_usd > 100000"]', + }); + expect(body).to.deep.equal({ + title: 'Whales', + filterSets: ['net_worth_usd > 100000'], + }); + }); + }); + + // ── Import ── + + describe('buildImportBody()', function () { + it('passes parsed addresses array + writeKey through', function () { + const body = buildImportBody({ + addresses: '["0xabc","0xdef"]', + writeKey: 'write_key_xyz', + }); + expect(body).to.deep.equal({ + addresses: ['0xabc', '0xdef'], + writeKey: 'write_key_xyz', + }); + }); + }); + + // ── Profiles update ── + + describe('buildUpdateProfileBody()', function () { + it('returns the parsed properties object verbatim', function () { + const body = buildUpdateProfileBody({ + properties: '{"display_name":"Vitalik","twitter":"VitalikButerin"}', + }); + expect(body).to.deep.equal({ + display_name: 'Vitalik', + twitter: 'VitalikButerin', + }); + }); + }); + + // ── Profiles labels create ── + + describe('buildCreateLabelBody()', function () { + it('produces a single-label object body when --tagId is given', function () { + const body = buildCreateLabelBody({ tagId: 'vip' }); + expect(body).to.deep.equal({ tag_id: 'vip' }); + }); + + it('translates value/chainId to snake_case in single-label mode', function () { + const body = buildCreateLabelBody({ + tagId: 'tier', + value: 'gold', + chainId: '1', + }); + expect(body).to.deep.equal({ + tag_id: 'tier', + value: 'gold', + chain_id: '1', + }); + }); + + it('produces an array body when --labels is given', function () { + const body = buildCreateLabelBody({ + labels: '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]', + }); + expect(body).to.deep.equal([ + { tag_id: 'vip' }, + { tag_id: 'airdrop_eligible', chain_id: '1' }, + ]); + }); + + it('--labels takes precedence over --tagId when both are provided', function () { + const body = buildCreateLabelBody({ + tagId: 'should-be-ignored', + labels: '[{"tag_id":"vip"}]', + }); + expect(body).to.deep.equal([{ tag_id: 'vip' }]); + }); + }); + + // ── Profiles labels delete ── + + describe('buildDeleteLabelBody()', function () { + it('builds a body with just tag_id', function () { + const body = buildDeleteLabelBody({ tagId: 'vip' }); + expect(body).to.deep.equal({ tag_id: 'vip' }); + }); + + it('includes chain_id when provided', function () { + const body = buildDeleteLabelBody({ tagId: 'tier', chainId: '1' }); + expect(body).to.deep.equal({ tag_id: 'tier', chain_id: '1' }); + }); + }); +}); diff --git a/test/commands/charts.test.ts b/test/commands/charts.test.ts index 1986eaa..8ae369b 100644 --- a/test/commands/charts.test.ts +++ b/test/commands/charts.test.ts @@ -1,35 +1,43 @@ import { expect } from 'chai'; import { listBoardsRun } from '../../src/commands/boards'; import { listChartsRun, getChartRun, createChartRun, updateChartRun } from '../../src/commands/charts'; +import { requiresLiveApi } from '../helpers/liveApi'; -// Response shape: { isSuccess: true, data: { charts: Chart[], board: ... } } for list -// { isSuccess: true, data: Chart } for get +// Response shape: PaginatedResponse + { board, warnings? } for list, +// Chart for get (bare resource — no envelope). describe('commands/charts', function () { let boardId: string | undefined; let firstChartId: string | undefined; before(async function () { - const res = await listBoardsRun() as { isSuccess: boolean; data: { id: string }[] }; + await requiresLiveApi(this); + const res = await listBoardsRun() as { data: { id: string }[] }; if (res.data.length > 0) boardId = res.data[0].id; }); describe('listChartsRun()', function () { - it('returns charts for a board', async function () { + it('returns paginated charts for a board with the parent board', async function () { if (!boardId) return this.skip(); - const res = await listChartsRun(boardId) as { isSuccess: boolean; data: { charts: { id: string }[] } }; - expect(res.isSuccess).to.equal(true); - expect(res.data.charts).to.be.an('array'); - if (res.data.charts.length > 0) firstChartId = res.data.charts[0].id; + const res = await listChartsRun(boardId) as { + data: { id: string }[]; + board: { id: string }; + total: number; + has_more: boolean; + }; + expect(res.data).to.be.an('array'); + expect(res.board).to.have.property('id'); + expect(res).to.have.property('total'); + expect(res).to.have.property('has_more'); + if (res.data.length > 0) firstChartId = res.data[0].id; }); }); describe('getChartRun()', function () { it('returns a chart by ID', async function () { if (!boardId || !firstChartId) return this.skip(); - const res = await getChartRun(boardId, firstChartId) as { isSuccess: boolean; data: { id: string } }; - expect(res.isSuccess).to.equal(true); - expect(res.data).to.have.property('id', firstChartId); + const res = await getChartRun(boardId, firstChartId) as { id: string }; + expect(res).to.have.property('id', firstChartId); }); }); diff --git a/test/commands/contracts.test.ts b/test/commands/contracts.test.ts index afe9122..86712ca 100644 --- a/test/commands/contracts.test.ts +++ b/test/commands/contracts.test.ts @@ -1,17 +1,27 @@ import { expect } from 'chai'; import { listContractsRun, createContractRun, updateContractRun } from '../../src/commands/contracts'; +import { requiresLiveApi } from '../helpers/liveApi'; -// Response shape: { isSuccess: true, data: { contracts: Contract[], deployAt, deployDiff } } +// Response shape: PaginatedResponse + { deploy: { last_deployed_at, diff } } for list +// (bare resource — no envelope). const TEST_ABI = JSON.stringify([{ type: 'event', name: 'Transfer', inputs: [] }]); const TEST_EVENTS = JSON.stringify({ Transfer: true }); describe('commands/contracts', function () { describe('listContractsRun()', function () { - it('returns an array of contracts', async function () { - const res = await listContractsRun() as { isSuccess: boolean; data: { contracts: unknown[] } }; - expect(res.isSuccess).to.equal(true); - expect(res.data.contracts).to.be.an('array'); + it('returns paginated contracts with deploy status', async function () { + await requiresLiveApi(this); + const res = await listContractsRun() as { + data: unknown[]; + deploy: { last_deployed_at: string | null; diff: unknown[] }; + total: number; + has_more: boolean; + }; + expect(res.data).to.be.an('array'); + expect(res.deploy).to.have.property('diff'); + expect(res).to.have.property('total'); + expect(res).to.have.property('has_more'); }); }); diff --git a/test/commands/profiles.test.ts b/test/commands/profiles.test.ts index 3940043..fcf5710 100644 --- a/test/commands/profiles.test.ts +++ b/test/commands/profiles.test.ts @@ -1,5 +1,12 @@ import { expect } from 'chai'; -import { getProfileRun, searchProfilesRun } from '../../src/commands/profiles'; +import { + createProfileLabelRun, + deleteProfileLabelRun, + getProfileRun, + searchProfilesRun, + updateProfileRun, +} from '../../src/commands/profiles'; +import { requiresLiveApi } from '../helpers/liveApi'; // Vitalik's address — publicly known, should always return a profile const KNOWN_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; @@ -7,26 +14,30 @@ const KNOWN_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; describe('commands/profiles', function () { describe('getProfileRun()', function () { it('returns a profile object for a known address', async function () { + await requiresLiveApi(this); const result = await getProfileRun(KNOWN_ADDRESS) as Record; expect(result).to.be.an('object'); }); it('encodes address and accepts an expand param', async function () { + await requiresLiveApi(this); const result = await getProfileRun(KNOWN_ADDRESS, 'chains') as Record; expect(result).to.be.an('object'); }); }); describe('searchProfilesRun()', function () { - it('returns a list of profiles with a limit', async function () { - const result = await searchProfilesRun({ limit: 3 }) as unknown; - // API may return array or { data: [...] } — either way it's defined + it('returns a paginated list of profiles', async function () { + await requiresLiveApi(this); + const result = await searchProfilesRun({ size: 3 }) as unknown; + // PaginatedResponse: { data, total, page, size, has_more } expect(result).to.exist; }); it('accepts orderBy and orderDir params', async function () { + await requiresLiveApi(this); const result = await searchProfilesRun({ - limit: 2, + size: 2, orderBy: 'net_worth_usd', orderDir: 'desc', }) as unknown; @@ -45,4 +56,50 @@ describe('commands/profiles', function () { ).to.throw(/conditions/); }); }); + + describe('updateProfileRun() — local validation', function () { + it('throws on invalid properties JSON', function () { + expect(() => + updateProfileRun(KNOWN_ADDRESS, { properties: 'not-json' }), + ).to.throw(/properties/); + }); + + it('throws when properties is not an object', function () { + expect(() => + updateProfileRun(KNOWN_ADDRESS, { properties: '[1,2,3]' }), + ).to.throw(/properties/); + }); + + it('throws when properties is empty', function () { + expect(() => + updateProfileRun(KNOWN_ADDRESS, { properties: '{}' }), + ).to.throw(/at least one key/); + }); + }); + + describe('createProfileLabelRun() — local validation', function () { + it('throws when neither --tagId nor --labels is provided', function () { + expect(() => createProfileLabelRun(KNOWN_ADDRESS, {})).to.throw(/tagId|labels/); + }); + + it('throws on invalid labels JSON', function () { + expect(() => + createProfileLabelRun(KNOWN_ADDRESS, { labels: 'not-json' }), + ).to.throw(/labels/); + }); + + it('throws when labels is not a non-empty array', function () { + expect(() => + createProfileLabelRun(KNOWN_ADDRESS, { labels: '[]' }), + ).to.throw(/labels/); + }); + }); + + describe('deleteProfileLabelRun() — local validation', function () { + it('throws when --tagId is missing', function () { + expect(() => + deleteProfileLabelRun(KNOWN_ADDRESS, { tagId: '' }), + ).to.throw(/tagId/); + }); + }); }); diff --git a/test/commands/query.test.ts b/test/commands/query.test.ts index f147248..b2fd8ae 100644 --- a/test/commands/query.test.ts +++ b/test/commands/query.test.ts @@ -1,15 +1,18 @@ import { expect } from 'chai'; import { queryRunRun } from '../../src/commands/query'; +import { requiresLiveApi } from '../helpers/liveApi'; describe('commands/query', function () { describe('queryRunRun()', function () { it('executes a SQL query and returns a result', async function () { + await requiresLiveApi(this); // Use a simple introspection query that should always succeed const result = await queryRunRun('SELECT 1 AS value') as unknown; expect(result).to.exist; }); it('passes arbitrary SQL to the API', async function () { + await requiresLiveApi(this); const result = await queryRunRun( 'SELECT count(*) AS total FROM events LIMIT 1', ) as unknown; diff --git a/test/commands/segments.test.ts b/test/commands/segments.test.ts index 9a40dac..2f4ae62 100644 --- a/test/commands/segments.test.ts +++ b/test/commands/segments.test.ts @@ -1,14 +1,17 @@ import { expect } from 'chai'; import { listSegmentsRun, createSegmentRun } from '../../src/commands/segments'; +import { requiresLiveApi } from '../helpers/liveApi'; -// Response shape: { isSuccess: true, data: Segment[] } +// Response shape: PaginatedResponse { data, total, page, size, has_more } (no envelope). describe('commands/segments', function () { describe('listSegmentsRun()', function () { - it('returns an array of segments', async function () { - const res = await listSegmentsRun() as { isSuccess: boolean; data: unknown[] }; - expect(res.isSuccess).to.equal(true); + it('returns a paginated list of segments', async function () { + await requiresLiveApi(this); + const res = await listSegmentsRun() as { data: unknown[]; total: number; has_more: boolean }; expect(res.data).to.be.an('array'); + expect(res).to.have.property('total'); + expect(res).to.have.property('has_more'); }); }); diff --git a/test/helpers/liveApi.ts b/test/helpers/liveApi.ts new file mode 100644 index 0000000..113b900 --- /dev/null +++ b/test/helpers/liveApi.ts @@ -0,0 +1,60 @@ +/** + * Probes the live API once with the configured key. If it returns 401, + * integration tests in this run are skipped with a clear message rather + * than each test individually failing on auth. + * + * Tests that hit the network call `requiresLiveApi(this)` in a `before` + * (or directly in the test) to opt into the skip. + */ +import type { Context } from 'mocha'; + +const API_BASE_URL = 'https://api.formo.so'; + +let probeStatus: 'unknown' | 'ok' | 'unauthorized' | 'unreachable' = 'unknown'; +let probePromise: Promise | undefined; + +async function probe(): Promise { + const apiKey = process.env.FORMO_API_KEY; + if (!apiKey) { + probeStatus = 'unauthorized'; + return; + } + + try { + const res = await fetch(`${API_BASE_URL}/api/validate-api-key`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apiKey }), + }); + if (res.status === 401 || res.status === 403) { + probeStatus = 'unauthorized'; + process.stderr.write( + `\n ⚠ Integration tests skipped: TEST_TOKEN was rejected by ${API_BASE_URL} (HTTP ${res.status}).\n` + + ` Refresh the FORMO test API key and update the TEST_TOKEN secret.\n\n`, + ); + return; + } + if (!res.ok) { + probeStatus = 'unreachable'; + process.stderr.write( + `\n ⚠ Integration tests skipped: probe to ${API_BASE_URL} returned HTTP ${res.status}.\n\n`, + ); + return; + } + probeStatus = 'ok'; + } catch (err) { + probeStatus = 'unreachable'; + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write( + `\n ⚠ Integration tests skipped: ${API_BASE_URL} is unreachable (${msg}).\n\n`, + ); + } +} + +export async function requiresLiveApi(ctx: Context): Promise { + if (probeStatus === 'unknown') { + if (!probePromise) probePromise = probe(); + await probePromise; + } + if (probeStatus !== 'ok') ctx.skip(); +} diff --git a/test/lib/parseApiError.test.ts b/test/lib/parseApiError.test.ts new file mode 100644 index 0000000..8950820 --- /dev/null +++ b/test/lib/parseApiError.test.ts @@ -0,0 +1,140 @@ +import { expect } from 'chai'; +import type { AxiosError } from 'axios'; +import { parseApiError } from '../../src/lib/client'; + +function makeAxiosError(args: { + status?: number; + data?: unknown; + message?: string; + code?: string; +}): AxiosError { + return { + isAxiosError: true, + name: 'AxiosError', + message: args.message ?? 'Request failed', + code: args.code, + config: {} as never, + response: + args.status !== undefined + ? { + status: args.status, + statusText: '', + headers: {}, + config: {} as never, + data: args.data, + } + : undefined, + toJSON: () => ({}), + } as AxiosError; +} + +describe('lib/client / parseApiError', function () { + it('decodes the canonical { error: { code, message, doc_url } } envelope', function () { + const err = parseApiError( + makeAxiosError({ + status: 404, + data: { + error: { + code: 'NOT_FOUND', + message: 'Alert not found', + doc_url: 'https://docs.formo.so/api/errors#not_found', + }, + }, + }), + ); + + expect(err.code).to.equal('NOT_FOUND'); + expect(err.docUrl).to.equal('https://docs.formo.so/api/errors#not_found'); + expect(err.status).to.equal(404); + expect(err.message).to.include('[NOT_FOUND] Alert not found'); + expect(err.message).to.include('https://docs.formo.so/api/errors#not_found'); + }); + + it('surfaces param when present', function () { + const err = parseApiError( + makeAxiosError({ + status: 400, + data: { + error: { + code: 'BAD_REQUEST', + message: 'Chain ID 999999 not supported', + doc_url: 'https://docs.formo.so/api/errors#bad_request', + param: 'chainId', + }, + }, + }), + ); + + expect(err.param).to.equal('chainId'); + expect(err.message).to.include('Param: chainId'); + }); + + it('preserves details on validation errors', function () { + const details = { + 'body.name': 'Required', + 'body.conditions.0.operator': 'Expected one of: gt, lt, eq', + }; + const err = parseApiError( + makeAxiosError({ + status: 400, + data: { + error: { + code: 'INVALID_VALIDATION_REQUEST', + message: 'Request validation failed', + doc_url: 'https://docs.formo.so/api/errors#invalid_validation_request', + details, + }, + }, + }), + ); + + expect(err.code).to.equal('INVALID_VALIDATION_REQUEST'); + expect(err.details).to.deep.equal(details); + }); + + it('falls back to axios message when the body has no error envelope', function () { + const err = parseApiError( + makeAxiosError({ + status: 401, + data: undefined, + message: 'Request failed with status code 401', + }), + ); + + expect(err.code).to.be.undefined; + expect(err.docUrl).to.be.undefined; + expect(err.message).to.equal('Request failed with status code 401'); + }); + + it('handles transport errors with no response', function () { + const err = parseApiError( + makeAxiosError({ + message: 'getaddrinfo ENOTFOUND api.formo.so', + code: 'ENOTFOUND', + }), + ); + + expect(err.code).to.be.undefined; + expect(err.status).to.be.undefined; + expect(err.transportCode).to.equal('ENOTFOUND'); + expect(err.message).to.equal('getaddrinfo ENOTFOUND api.formo.so'); + }); + + it('omits Param/Docs lines when absent', function () { + const err = parseApiError( + makeAxiosError({ + status: 500, + data: { + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Something went wrong', + }, + }, + }), + ); + + expect(err.message).to.equal('[INTERNAL_SERVER_ERROR] Something went wrong'); + expect(err.message).to.not.include('Param:'); + expect(err.message).to.not.include('Docs:'); + }); +});