diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0cc9a79..27aa0a9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,7 +11,9 @@ permissions:
jobs:
publish:
- runs-on: blacksmith-4vcpu-ubuntu-2404
+ # npm provenance publishing only supports GitHub-hosted Actions runners.
+ # Keep this job on ubuntu-latest unless npm adds self-hosted provenance support.
+ runs-on: ubuntu-latest
# Workflow-context values are bound to env here and referenced as
# shell variables ($TAG/$REPO/$COMMIT_SHA) in run: blocks instead of
# `${{ }}` interpolation, so an attacker-controlled tag name cannot be
diff --git a/.gitignore b/.gitignore
index 9fbdbb5..61a6611 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,6 @@ pnpm-debug.log*
# Test coverage
coverage/
+
+# Security scan output
+.deepsec/
diff --git a/README.md b/README.md
index 1d4d904..602c065 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,13 @@ export FORMO_API_KEY=formo_abc123
Get your API key from `Settings → API Keys` in the [Formo dashboard](https://app.formo.so).
+For local development or proxying, override API hosts with:
+
+```bash
+export FORMO_API_BASE_URL=http://localhost:3001
+export FORMO_EVENTS_BASE_URL=http://localhost:3002
+```
+
---
## Auth commands
@@ -114,6 +121,15 @@ formo profiles update vitalik.eth --properties '{"email":"alice@example.com"}'
> Requires `profiles:write` scope.
+### `profiles properties batch`
+
+Batch update first-party properties for up to 100 wallets.
+
+```bash
+formo profiles properties batch \
+ --rows '[{"address":"0xd8dA...","display_name":"alice.eth","email":"alice@example.com"}]'
+```
+
### `profiles labels create
`
Upsert one or more labels on a wallet profile. Provide either a single label via `--tag-id` or a batch via `--labels`.
@@ -123,12 +139,16 @@ Upsert one or more labels on a wallet profile. Provide either a single label via
| `--tag-id` | Label identifier (e.g. `vip`, `airdrop_eligible`) |
| `--value` | Optional label value (e.g. tier name, country code) |
| `--chain-id` | Optional chain identifier the label applies to |
+| `--timestamp` | Optional historical ISO-8601 timestamp |
+| `--is-deleted` | Backfill a historical label removal tombstone |
| `--labels` | JSON array of `UserLabelInput` objects for batch upsert |
```bash
formo profiles labels create 0xd8dA... --tag-id vip
formo profiles labels create 0xd8dA... --tag-id tier --value gold --chain-id 1
formo profiles labels create 0xd8dA... --labels '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]'
+formo profiles labels create 0xd8dA... --tag-id tier --timestamp 2024-03-15T00:00:00.000Z --is-deleted
+formo profiles labels batch --labels '[{"address":"0xd8dA...","tag_id":"vip","value":"tier-1"}]'
```
### `profiles labels delete `
@@ -164,7 +184,7 @@ Get a single alert by ID.
| Option | Description |
|---|---|
| `--name` | Alert name |
-| `--trigger-type` | Trigger type (e.g. `event`, `threshold`) |
+| `--trigger-type` | Trigger type: `event` or `user` |
| `--trigger-filters` | JSON array of trigger filter objects |
| `--recipient` | JSON array of recipient objects |
| `--secret` | Webhook secret |
@@ -181,11 +201,18 @@ Same options as `create`. Replaces the alert configuration.
### `alerts delete `
Delete an alert.
-### `alerts toggle --status `
-Toggle an alert between `active` and `paused`.
+### `alerts toggle --status `
+Toggle an alert between `active` and `inactive`.
+
+```bash
+formo alerts toggle alert_abc123 --status inactive
+```
+
+### `alerts test `
+Send a test alert delivery with optional sample payloads.
```bash
-formo alerts toggle alert_abc123 --status paused
+formo alerts test alert_abc123 --sample-event '{"event":"transaction","revenue":250}'
```
---
@@ -204,19 +231,21 @@ Get a single board by ID.
| Option | Description |
|---|---|
-| `--name` | Board name |
+| `--title` | Board title |
| `--description` | Optional board description |
+| `--is-public` | Make the board publicly viewable |
```bash
-formo boards create --name "Revenue Metrics" --description "Weekly revenue tracking"
+formo boards create --title "Revenue Metrics" --description "Weekly revenue tracking"
```
### `boards update `
| Option | Description |
|---|---|
-| `--name` | New board name |
+| `--title` | New board title |
| `--description` | New board description |
+| `--is-public` | Update public visibility |
### `boards delete `
Delete a board.
@@ -233,12 +262,30 @@ List all charts in a board.
### `charts get --board-id `
Get a single chart by ID.
-### `charts create --board-id --body ''`
-Create a chart from a JSON config string.
+### `charts meta --board-id `
+List lightweight chart metadata without executing chart queries.
+
+### `charts create --board-id [options]`
+Create a chart from typed flags or a raw JSON body.
+
+```bash
+formo charts create --board-id brd_123 --title "Daily Active Users" \
+ --chart-type line \
+ --query "SELECT toDate(timestamp) AS date, countDistinct(address) AS users FROM events GROUP BY date ORDER BY date" \
+ --x-axis date --y-axis users
+
+formo charts create --board-id brd_123 --body '{"title":"Recent Events","chart_type":"table","query":"SELECT * FROM events LIMIT 10"}'
+```
### `charts update --board-id --body ''`
Update a chart.
+### `charts query --board-id --date-from --date-to `
+Execute a saved chart that uses `{{date_from}}` / `{{date_to}}` variables.
+
+### `charts move|duplicate|reorder`
+Move a chart to another board, duplicate a chart, or reorder charts in a board.
+
### `charts delete --board-id `
Delete a chart.
@@ -251,6 +298,12 @@ 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 get `
+Get a single tracked contract.
+
+### `contracts recommendations`
+List contracts the project already interacts with but has not added yet.
+
### `contracts create`
| Option | Description |
@@ -258,13 +311,15 @@ List all tracked contracts. Returns `{ data: Contract[], deploy: { last_deployed
| `--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 |
+| `--abi` | Contract ABI as a JSON string; sent stringified to the API |
+| `--events` | JSON array of ABI event objects to monitor |
+| `--start-block` | Optional start block |
+| `--include-in-pipeline` | Include this contract in the Goldsky events pipeline (`true` by default in the API) |
```bash
formo contracts create --address 0x1f9840a85d5af5bf1d1762f925bdaddc4201f984 --chain 1 \
--name "UNI Token" --abi '[{"type":"event","name":"Transfer","inputs":[]}]' \
- --events '{"Transfer":true}'
+ --events '[{"type":"event","name":"Transfer","inputs":[]}]'
```
### `contracts update `
@@ -273,7 +328,12 @@ formo contracts create --address 0x1f9840a85d5af5bf1d1762f925bdaddc4201f984 --ch
|---|---|
| `--name` | Updated contract name |
| `--abi` | Updated ABI |
-| `--events` | Updated events config |
+| `--events` | Updated JSON array of ABI event objects |
+| `--start-block` | Optional start block |
+| `--include-in-pipeline` | Include or exclude this contract from the Goldsky events pipeline |
+
+### `contracts pipeline --include-in-pipeline `
+Toggle pipeline inclusion without re-sending the full ABI/events payload.
### `contracts delete `
Remove a tracked contract.
@@ -348,10 +408,24 @@ Bulk-import wallet addresses into the project via the events API.
| Option | Description |
|---|---|
| `--addresses` | JSON array of wallet address strings |
-| `--write-key` | Project write SDK key |
+| `--rows` | JSON array of `{address,properties?}` objects |
+
+```bash
+formo import wallets --addresses '["0xabc...","0xdef..."]'
+formo import wallets --rows '[{"address":"0xabc...","properties":{"display_name":"Alice"}}]'
+```
+
+---
+
+## `formo events`
+
+### `events ingest`
+
+Send raw analytics events to `events.formo.so`. This command uses a project SDK write key, not the workspace API key.
```bash
-formo import wallets --addresses '["0xabc...","0xdef..."]' --write-key write_key_xyz
+export FORMO_WRITE_KEY=formo_write_key_xxx
+formo events ingest --event '{"type":"track","channel":"cli","version":"1","anonymous_id":"anon_123","event":"CLI Test","context":{},"properties":{},"original_timestamp":"2026-04-27T23:05:38.000Z","sent_at":"2026-04-27T23:05:42.000Z","message_id":"cli-test-1"}'
```
---
diff --git a/SKILLS.md b/SKILLS.md
index f1c3c56..4679811 100644
--- a/SKILLS.md
+++ b/SKILLS.md
@@ -14,6 +14,8 @@ formo login
Saves your key to `~/.config/formo/config.json`. The `FORMO_API_KEY` environment variable takes precedence if set.
+For local development, set `FORMO_API_BASE_URL` for the REST API host and `FORMO_EVENTS_BASE_URL` for event ingestion.
+
### Check authentication status
```bash
@@ -65,6 +67,7 @@ formo profiles search [options]
| Option | Values | Description |
|---|---|---|
| `--address` | `string` | Filter to a specific wallet address |
+| `--search` | `string` | Free-text search across address and identity fields |
| `--page` | `number` | Page number (1-indexed, default `1`) |
| `--size` | `number` | Page size (default `100`, max `1000`) |
| `--order-by` | see below | Field to sort by |
@@ -119,6 +122,28 @@ Combine multiple conditions with `--logic and` (default) or `--logic or`.
---
+### Update profile properties
+
+```bash
+formo profiles update --properties '{"display_name":"Alice","twitter":"alice"}'
+formo profiles properties batch --rows '[{"address":"0xabc...","display_name":"Alice"}]'
+```
+
+> Requires `profiles:write` scope.
+
+### Manage profile labels
+
+```bash
+formo profiles labels create --tag-id vip --value tier-1
+formo profiles labels create --tag-id tier --timestamp 2024-03-15T00:00:00.000Z --is-deleted
+formo profiles labels batch --labels '[{"address":"0xabc...","tag_id":"vip","value":"tier-1"}]'
+formo profiles labels delete --tag-id vip
+```
+
+> Requires `profiles:write` scope.
+
+---
+
## SQL Analytics Queries
### Run a SQL query
@@ -219,7 +244,7 @@ formo alerts create --name --trigger-type [options]
| Option | Description |
|---|---|
| `--name` | Alert name |
-| `--trigger-type` | Trigger type (e.g. `event`, `threshold`) |
+| `--trigger-type` | Trigger type: `event` or `user` |
| `--trigger-filters` | JSON array of trigger filter objects (optional) |
| `--recipient` | JSON array of recipient objects (optional) |
| `--secret` | Webhook secret string (optional) |
@@ -232,9 +257,9 @@ formo alerts create --name --trigger-type [options]
formo alerts create --name "High value tx" --trigger-type event
# Create an alert with filters and recipients
-formo alerts create --name "Whale alert" --trigger-type threshold \
- --trigger-filters '[{"field":"amount","op":"gt","value":100000}]' \
- --recipient '["https://hooks.example.com/formo"]'
+formo alerts create --name "Whale alert" --trigger-type event \
+ --trigger-filters '[{"name":"revenue","operator":"greater_than","value":"100000"}]' \
+ --recipient '[{"type":"webhook","value":["https://hooks.example.com/formo"]}]'
```
### Update an alert
@@ -256,24 +281,30 @@ formo alerts delete
### Toggle alert status
```bash
-formo alerts toggle --status
+formo alerts toggle --status
```
| Option | Values | Description |
|---|---|---|
-| `--status` | `active`, `paused` | New alert status |
+| `--status` | `active`, `inactive` | New alert status |
> Requires `alerts:write` scope.
**Examples:**
```bash
-# Pause an alert
-formo alerts toggle alert_abc123 --status paused
+# Deactivate an alert
+formo alerts toggle alert_abc123 --status inactive
# Re-activate an alert
formo alerts toggle alert_abc123 --status active
```
+### Test alert delivery
+
+```bash
+formo alerts test --sample-event '{"event":"transaction","revenue":250}'
+```
+
---
## Dashboard Boards
@@ -299,26 +330,27 @@ formo boards get
### Create a board
```bash
-formo boards create --name [--description ]
+formo boards create --title [--description ] [--is-public]
```
| Option | Description |
|---|---|
-| `--name` | Board name |
+| `--title` | Board title |
| `--description` | Board description (optional) |
+| `--is-public` | Whether the board is publicly viewable |
> Requires `boards:write` scope.
**Examples:**
```bash
-formo boards create --name "KPI Dashboard"
-formo boards create --name "Revenue Metrics" --description "Weekly revenue tracking"
+formo boards create --title "KPI Dashboard"
+formo boards create --title "Revenue Metrics" --description "Weekly revenue tracking"
```
### Update a board
```bash
-formo boards update [--name ] [--description ]
+formo boards update [--title ] [--description ] [--is-public]
```
> Requires `boards:write` scope.
@@ -345,6 +377,14 @@ formo charts list --board-id
> Requires `boards:read` scope.
+### List chart metadata
+
+```bash
+formo charts meta --board-id
+```
+
+> Requires `boards:read` scope.
+
### Get a single chart
```bash
@@ -356,20 +396,27 @@ formo charts get --board-id
### Create a chart
```bash
-formo charts create --board-id --body ''
+formo charts create --board-id [--body '' | typed chart options]
```
| Option | Description |
|---|---|
| `--board-id` | Board ID to add the chart to |
| `--body` | Full chart configuration as a JSON string |
+| `--title` | Chart title |
+| `--chart-type` | `table`, `number`, `funnel`, `bar`, `line`, `area`, `pie`, `stacked`, `user_paths`, `retention` |
+| `--query` | SQL query for SQL-backed charts |
+| `--x-axis` / `--y-axis` / `--group-by` | Chart encodings |
+| `--steps` / `--settings` | JSON for funnel/user-path/retention chart settings |
> Requires `boards:write` scope.
**Examples:**
```bash
formo charts create --board-id board_abc123 \
- --body '{"name":"Daily active users","chartType":"line"}'
+ --title "Daily active users" --chart-type line \
+ --query "SELECT toDate(timestamp) AS date, countDistinct(address) AS users FROM events GROUP BY date ORDER BY date" \
+ --x-axis date --y-axis users
```
### Update a chart
@@ -380,6 +427,15 @@ formo charts update --board-id --body ''
> Requires `boards:write` scope.
+### Query, move, duplicate, or reorder charts
+
+```bash
+formo charts query --board-id --date-from 2026-04-01 --date-to 2026-04-30
+formo charts move --board-id --target-board-id
+formo charts duplicate --board-id
+formo charts reorder --board-id --chart-ids chart_a,chart_b,chart_c
+```
+
### Delete a chart
```bash
@@ -402,6 +458,24 @@ formo contracts list
> Requires `contracts:read` scope.
+### Get a contract
+
+```bash
+formo contracts get
+```
+
+> Requires `contracts:read` scope.
+
+### Get recommended contracts
+
+```bash
+formo contracts recommendations
+```
+
+Lists contracts the project already interacts with but has not added yet.
+
+> Requires `contracts:read` scope.
+
### Register a contract
```bash
@@ -414,7 +488,9 @@ formo contracts create --address --chain --name --abi '<
| `--chain` | Chain ID (e.g. `1` for Ethereum, `137` for Polygon) |
| `--name` | Human-readable contract name |
| `--abi` | Contract ABI as a JSON string |
-| `--events` | Events configuration as a JSON string |
+| `--events` | JSON array of ABI event objects to monitor |
+| `--start-block` | Optional start block |
+| `--include-in-pipeline` | Include this contract in the Goldsky events pipeline |
> Requires `contracts:write` scope.
@@ -425,7 +501,7 @@ formo contracts create \
--chain 1 \
--name "My Token" \
--abi '[{"type":"event","name":"Transfer","inputs":[]}]' \
- --events '{"Transfer":true}'
+ --events '[{"type":"event","name":"Transfer","inputs":[]}]'
```
### Update a contract
@@ -441,6 +517,16 @@ formo contracts update --name --abi '' --events '
> Requires `contracts:write` scope.
+### Toggle pipeline inclusion
+
+```bash
+formo contracts pipeline --include-in-pipeline false
+```
+
+Use this when a contract should remain registered for ABI decoding but be excluded from pipeline deploys.
+
+> Requires `contracts:write` scope.
+
### Delete a contract
```bash
@@ -496,21 +582,35 @@ formo segments delete
Bulk import wallet addresses into a project to track them. This creates identify events for each address.
```bash
-formo import wallets --addresses '' --write-key
+formo import wallets --addresses ''
+formo import wallets --rows ''
```
| Option | Description |
|---|---|
| `--addresses` | JSON array of wallet address strings to import |
-| `--write-key` | Project write SDK key |
+| `--rows` | JSON array of `{address,properties?}` objects |
> Requires `profiles:write` scope. **Only available on Scale and Enterprise plans.**
**Examples:**
```bash
formo import wallets \
- --addresses '["0xabc123…","0xdef456…"]' \
- --write-key write_key_xxx
+ --addresses '["0xabc123…","0xdef456…"]'
+
+formo import wallets \
+ --rows '[{"address":"0xabc123…","properties":{"display_name":"Alice"}}]'
+```
+
+---
+
+## Raw Event Ingestion
+
+Send analytics events to `events.formo.so` with a project SDK write key.
+
+```bash
+export FORMO_WRITE_KEY=formo_write_key_xxx
+formo events ingest --event '{"type":"track","channel":"cli","version":"1","anonymous_id":"anon_123","event":"CLI Test","context":{},"properties":{},"original_timestamp":"2026-04-27T23:05:38.000Z","sent_at":"2026-04-27T23:05:42.000Z","message_id":"cli-test-1"}'
```
---
diff --git a/src/commands/alerts.ts b/src/commands/alerts.ts
index bde37cc..e450a73 100644
--- a/src/commands/alerts.ts
+++ b/src/commands/alerts.ts
@@ -1,5 +1,6 @@
import { Cli, z } from 'incur'
import { createClient, requireApiKey } from '../lib/client'
+import { parseJsonArray, parseJsonObject } from '../lib/json'
export const alerts = Cli.create('alerts', {
description: 'Project alert commands — create, list, update, and delete alerts',
@@ -7,18 +8,34 @@ export const alerts = Cli.create('alerts', {
// ── List alerts ──
-export function listAlertsRun() {
+export interface PaginationOptions {
+ page?: number
+ size?: number
+}
+
+function buildPaginationParams(options: PaginationOptions = {}) {
+ const params: Record = {}
+ if (options.page !== undefined) params.page = options.page
+ if (options.size !== undefined) params.size = options.size
+ return params
+}
+
+export function listAlertsRun(options: PaginationOptions = {}) {
requireApiKey()
const client = createClient()
- return client.get('/v0/alerts/')
+ return client.get('/v0/alerts/', { params: buildPaginationParams(options) })
}
alerts.command('list', {
description: 'List all alerts for the project',
+ options: z.object({
+ page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'),
+ size: z.coerce.number().optional().describe('Page size (default 100, max 200)'),
+ }),
examples: [{ description: 'List all project alerts' }],
hint: 'Requires alerts:read scope on your API key.',
- run() {
- return listAlertsRun()
+ run({ options }) {
+ return listAlertsRun(options)
},
})
@@ -48,38 +65,42 @@ alerts.command('get', {
export interface CreateAlertOptions {
name: string
- triggerType: string
+ triggerType: 'event' | 'user' | string
triggerFilters?: string
recipient?: string
secret?: string
+ slackPropertyKeys?: string
}
export function buildAlertBody(options: CreateAlertOptions | UpdateAlertOptions) {
const body: Record = {
name: options.name,
trigger_type: options.triggerType,
+ trigger_filters: [],
}
if (options.triggerFilters) {
- try {
- body.trigger_filters = JSON.parse(options.triggerFilters)
- } catch {
- throw new Error('--trigger-filters must be a valid JSON array')
- }
+ body.trigger_filters = parseJsonArray(
+ options.triggerFilters,
+ '--trigger-filters',
+ )
}
if (options.recipient) {
- try {
- body.recipient = JSON.parse(options.recipient)
- } catch {
- throw new Error('--recipient must be a valid JSON array')
- }
+ body.recipient = parseJsonArray(options.recipient, '--recipient')
}
if (options.secret !== undefined) {
body.secret = options.secret
}
+ if (options.slackPropertyKeys !== undefined) {
+ body.slack_property_keys = parseJsonArray(
+ options.slackPropertyKeys,
+ '--slack-property-keys',
+ )
+ }
+
return body
}
@@ -93,7 +114,7 @@ alerts.command('create', {
description: 'Create a new project alert',
options: z.object({
name: z.string().describe('Alert name'),
- triggerType: z.string().describe('Trigger type (e.g. "event", "threshold")'),
+ triggerType: z.enum(['event', 'user']).describe('Trigger type'),
triggerFilters: z
.string()
.optional()
@@ -103,6 +124,10 @@ alerts.command('create', {
.optional()
.describe('JSON array of recipient objects'),
secret: z.string().optional().describe('Webhook secret for the alert'),
+ slackPropertyKeys: z
+ .string()
+ .optional()
+ .describe('JSON array of event/user property keys to include in Slack alerts'),
}),
examples: [
{
@@ -120,10 +145,11 @@ alerts.command('create', {
export interface UpdateAlertOptions {
name: string
- triggerType: string
+ triggerType: 'event' | 'user' | string
triggerFilters?: string
recipient?: string
secret?: string
+ slackPropertyKeys?: string
}
export function updateAlertRun(alertId: string, options: UpdateAlertOptions) {
@@ -142,7 +168,7 @@ alerts.command('update', {
}),
options: z.object({
name: z.string().describe('Alert name'),
- triggerType: z.string().describe('Trigger type'),
+ triggerType: z.enum(['event', 'user']).describe('Trigger type'),
triggerFilters: z
.string()
.optional()
@@ -152,6 +178,10 @@ alerts.command('update', {
.optional()
.describe('JSON array of recipient objects'),
secret: z.string().optional().describe('Webhook secret for the alert'),
+ slackPropertyKeys: z
+ .string()
+ .optional()
+ .describe('JSON array of event/user property keys to include in Slack alerts'),
}),
examples: [
{
@@ -193,22 +223,25 @@ alerts.command('delete', {
export function toggleAlertRun(alertId: string, status: string) {
requireApiKey()
const client = createClient()
- return client.patch(`/v0/alerts/${encodeURIComponent(alertId)}`, { status })
+ const normalizedStatus = status === 'paused' ? 'inactive' : status
+ return client.patch(`/v0/alerts/${encodeURIComponent(alertId)}`, {
+ status: normalizedStatus,
+ })
}
alerts.command('toggle', {
- description: 'Toggle an alert status (active/paused)',
+ description: 'Toggle an alert status (active/inactive)',
args: z.object({
alertId: z.string().describe('Alert ID to toggle'),
}),
options: z.object({
- status: z.enum(['active', 'paused']).describe('New status'),
+ status: z.enum(['active', 'inactive', 'paused']).describe('New status. "paused" is accepted as a deprecated alias for "inactive".'),
}),
examples: [
{
args: { alertId: 'alert_abc123' },
- options: { status: 'paused' },
- description: 'Pause an alert',
+ options: { status: 'inactive' },
+ description: 'Deactivate an alert',
},
],
hint: 'Requires alerts:write scope on your API key.',
@@ -216,3 +249,71 @@ alerts.command('toggle', {
return toggleAlertRun(args.alertId, options.status)
},
})
+
+// ── Test alert delivery ──
+
+export interface TestAlertOptions {
+ sampleEvent?: string
+ sampleUser?: string
+ recipientOverrides?: string
+}
+
+export function buildTestAlertBody(options: TestAlertOptions) {
+ const body: Record = {}
+ if (options.sampleEvent !== undefined) {
+ body.sampleEvent = parseJsonObject(options.sampleEvent, '--sample-event')
+ }
+ if (options.sampleUser !== undefined) {
+ body.sampleUser = parseJsonObject(options.sampleUser, '--sample-user')
+ }
+ if (options.recipientOverrides !== undefined) {
+ body.recipientOverrides = parseJsonArray(
+ options.recipientOverrides,
+ '--recipient-overrides',
+ )
+ }
+ return Object.keys(body).length > 0 ? body : undefined
+}
+
+export function testAlertRun(alertId: string, options: TestAlertOptions = {}) {
+ requireApiKey()
+ const client = createClient()
+ return client.post(
+ `/v0/alerts/${encodeURIComponent(alertId)}/test`,
+ buildTestAlertBody(options),
+ )
+}
+
+alerts.command('test', {
+ description: 'Send a test delivery for an alert',
+ args: z.object({
+ alertId: z.string().describe('Alert ID to test'),
+ }),
+ options: z.object({
+ sampleEvent: z
+ .string()
+ .optional()
+ .describe('Optional JSON object to use as the sample event'),
+ sampleUser: z
+ .string()
+ .optional()
+ .describe('Optional JSON object to use as the sample user/profile'),
+ recipientOverrides: z
+ .string()
+ .optional()
+ .describe('Optional JSON array of recipient objects to test instead of saved recipients'),
+ }),
+ examples: [
+ {
+ args: { alertId: 'alert_abc123' },
+ options: {
+ sampleEvent: '{"event":"transaction","revenue":250}',
+ },
+ description: 'Send a test alert with a sample event',
+ },
+ ],
+ hint: 'Requires alerts:write scope on your API key.',
+ run({ args, options }) {
+ return testAlertRun(args.alertId, options)
+ },
+})
diff --git a/src/commands/boards.ts b/src/commands/boards.ts
index c63ae72..d405961 100644
--- a/src/commands/boards.ts
+++ b/src/commands/boards.ts
@@ -5,20 +5,36 @@ export const boards = Cli.create('boards', {
description: 'Dashboard board commands — create, list, update, and delete boards',
})
+export interface PaginationOptions {
+ page?: number
+ size?: number
+}
+
+function buildPaginationParams(options: PaginationOptions = {}) {
+ const params: Record = {}
+ if (options.page !== undefined) params.page = options.page
+ if (options.size !== undefined) params.size = options.size
+ return params
+}
+
// ── List boards ──
-export function listBoardsRun() {
+export function listBoardsRun(options: PaginationOptions = {}) {
requireApiKey()
const client = createClient()
- return client.get('/v0/boards/')
+ return client.get('/v0/boards/', { params: buildPaginationParams(options) })
}
boards.command('list', {
description: 'List all boards for the project',
+ options: z.object({
+ page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'),
+ size: z.coerce.number().optional().describe('Page size (default 100, max 200)'),
+ }),
examples: [{ description: 'List all dashboard boards' }],
hint: 'Requires boards:read scope on your API key.',
- run() {
- return listBoardsRun()
+ run({ options }) {
+ return listBoardsRun(options)
},
})
@@ -47,17 +63,36 @@ boards.command('get', {
// ── Create a board ──
export interface CreateBoardOptions {
- name: string
+ title?: string
+ name?: string
description?: string
+ isPublic?: boolean
+}
+
+export function buildBoardBody(options: CreateBoardOptions | UpdateBoardOptions) {
+ const title = options.title ?? options.name
+ const body: Record = {}
+ if (title !== undefined) {
+ if (!title) throw new Error('--title must not be empty')
+ body.title = title
+ }
+ if (options.description !== undefined) {
+ body.description = options.description
+ }
+ if (options.isPublic !== undefined) {
+ body.isPublic = options.isPublic
+ }
+
+ return body
}
export function createBoardRun(options: CreateBoardOptions) {
requireApiKey()
const client = createClient()
- const body: Record = { name: options.name }
- if (options.description) {
- body.description = options.description
+ const body = buildBoardBody(options)
+ if (!body.title) {
+ throw new Error('Provide --title for the board name')
}
return client.post('/v0/boards/', body)
@@ -66,16 +101,18 @@ export function createBoardRun(options: CreateBoardOptions) {
boards.command('create', {
description: 'Create a new dashboard board',
options: z.object({
- name: z.string().describe('Board name'),
+ title: z.string().optional().describe('Board title'),
+ name: z.string().optional().describe('Deprecated alias for --title').meta({ deprecated: true }),
description: z.string().optional().describe('Board description'),
+ isPublic: z.boolean().optional().describe('Whether the board is publicly viewable'),
}),
examples: [
{
- options: { name: 'KPI Dashboard' },
+ options: { title: 'KPI Dashboard' },
description: 'Create a board',
},
{
- options: { name: 'Revenue Metrics', description: 'Weekly revenue tracking' },
+ options: { title: 'Revenue Metrics', description: 'Weekly revenue tracking' },
description: 'Create a board with description',
},
],
@@ -88,17 +125,20 @@ boards.command('create', {
// ── Update a board ──
export interface UpdateBoardOptions {
+ title?: string
name?: string
description?: string
+ isPublic?: boolean
}
export function updateBoardRun(boardId: string, options: UpdateBoardOptions) {
requireApiKey()
const client = createClient()
- const body: Record = {}
- if (options.name !== undefined) body.name = options.name
- if (options.description !== undefined) body.description = options.description
+ const body = buildBoardBody(options)
+ if (Object.keys(body).length === 0) {
+ throw new Error('Provide at least one of --title, --description, or --is-public')
+ }
return client.patch(`/v0/boards/${encodeURIComponent(boardId)}`, body)
}
@@ -109,13 +149,15 @@ boards.command('update', {
boardId: z.string().describe('Board ID to update'),
}),
options: z.object({
- name: z.string().optional().describe('New board name'),
+ title: z.string().optional().describe('New board title'),
+ name: z.string().optional().describe('Deprecated alias for --title').meta({ deprecated: true }),
description: z.string().optional().describe('New board description'),
+ isPublic: z.boolean().optional().describe('Whether the board is publicly viewable'),
}),
examples: [
{
args: { boardId: 'board_abc123' },
- options: { name: 'Renamed Board' },
+ options: { title: 'Renamed Board' },
description: 'Rename a board',
},
],
diff --git a/src/commands/charts.ts b/src/commands/charts.ts
index 9bd02f8..b14d894 100644
--- a/src/commands/charts.ts
+++ b/src/commands/charts.ts
@@ -1,22 +1,156 @@
import { Cli, z } from 'incur'
import { createClient, requireApiKey } from '../lib/client'
+import {
+ parseJsonArray,
+ parseJsonObject,
+ parseStringArray,
+} from '../lib/json'
+import { stripTrailingFormatClause } from '../lib/sql'
export const charts = Cli.create('charts', {
- description: 'Chart commands — create, list, update, and delete charts within boards',
+ description:
+ 'Chart commands — create, list, query, move, duplicate, reorder, update, and delete charts within boards',
+})
+
+const chartTypeSchema = z.enum([
+ 'table',
+ 'number',
+ 'funnel',
+ 'bar',
+ 'line',
+ 'area',
+ 'pie',
+ 'stacked',
+ 'user_paths',
+ 'retention',
+])
+
+export interface PaginationOptions {
+ page?: number
+ size?: number
+}
+
+function buildPaginationParams(options: PaginationOptions = {}) {
+ const params: Record = {}
+ if (options.page !== undefined) params.page = options.page
+ if (options.size !== undefined) params.size = options.size
+ return params
+}
+
+// ── Shared chart body builder ──
+
+export interface ChartBodyOptions {
+ body?: string
+ query?: string
+ chartType?: string
+ title?: string
+ description?: string
+ xAxis?: string
+ yAxis?: string
+ groupBy?: string
+ steps?: string
+ settings?: string
+}
+
+function hasTypedChartFields(options: ChartBodyOptions) {
+ return [
+ options.query,
+ options.chartType,
+ options.title,
+ options.description,
+ options.xAxis,
+ options.yAxis,
+ options.groupBy,
+ options.steps,
+ options.settings,
+ ].some((value) => value !== undefined)
+}
+
+export function buildChartBody(options: ChartBodyOptions) {
+ let body: Record = {}
+
+ if (options.body) {
+ body = parseJsonObject(options.body, '--body')
+ }
+
+ if (!options.body && !hasTypedChartFields(options)) {
+ throw new Error(
+ 'Provide --body or chart fields such as --title, --chart-type, and --query',
+ )
+ }
+
+ if (options.query !== undefined) {
+ body.query = stripTrailingFormatClause(options.query)
+ }
+ if (options.chartType !== undefined) body.chart_type = options.chartType
+ if (options.title !== undefined) body.title = options.title
+ if (options.description !== undefined) body.description = options.description
+ if (options.xAxis !== undefined) body.x_axis = options.xAxis
+ if (options.yAxis !== undefined) {
+ body.y_axis = parseStringArray(options.yAxis, '--y-axis')
+ }
+ if (options.groupBy !== undefined) body.group_by = options.groupBy
+ if (options.steps !== undefined) {
+ body.steps = parseJsonArray(options.steps, '--steps')
+ }
+ if (options.settings !== undefined) {
+ body.settings = parseJsonObject(options.settings, '--settings')
+ }
+
+ return body
+}
+
+function coerceChartBody(input: string | ChartBodyOptions) {
+ return typeof input === 'string' ? buildChartBody({ body: input }) : buildChartBody(input)
+}
+
+const chartBodyOptions = z.object({
+ body: z
+ .string()
+ .optional()
+ .describe('Raw JSON chart body. Typed flags below override matching body keys.'),
+ query: z
+ .string()
+ .optional()
+ .describe('SQL query powering table/number/bar/line/area/pie/stacked charts'),
+ chartType: chartTypeSchema.optional().describe('Chart type'),
+ title: z.string().optional().describe('Chart title'),
+ description: z.string().optional().describe('Optional chart description'),
+ xAxis: z.string().optional().describe('Column used as the x axis'),
+ yAxis: z
+ .string()
+ .optional()
+ .describe('Comma-separated or JSON array of y-axis metric columns'),
+ groupBy: z.string().optional().describe('Column used to group or stack series'),
+ steps: z
+ .string()
+ .optional()
+ .describe('JSON array of funnel/user-path step objects'),
+ settings: z
+ .string()
+ .optional()
+ .describe('JSON object of type-specific chart settings'),
})
// ── List charts for a board ──
-export function listChartsRun(boardId: string) {
+export function listChartsRun(
+ boardId: string,
+ options: PaginationOptions = {},
+) {
requireApiKey()
const client = createClient()
- return client.get(`/v0/boards/${encodeURIComponent(boardId)}/charts/`)
+ return client.get(`/v0/boards/${encodeURIComponent(boardId)}/charts/`, {
+ params: buildPaginationParams(options),
+ })
}
charts.command('list', {
- description: 'List all charts for a board',
+ description: 'List all charts for a board, including executed results',
options: z.object({
boardId: z.string().describe('Board ID to list charts from'),
+ page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'),
+ size: z.coerce.number().optional().describe('Page size (default 100, max 200)'),
}),
examples: [
{
@@ -26,7 +160,39 @@ charts.command('list', {
],
hint: 'Requires boards:read scope on your API key.',
run({ options }) {
- return listChartsRun(options.boardId)
+ return listChartsRun(options.boardId, options)
+ },
+})
+
+// ── List chart metadata for a board ──
+
+export function listChartSummariesRun(
+ boardId: string,
+ options: PaginationOptions = {},
+) {
+ requireApiKey()
+ const client = createClient()
+ return client.get(`/v0/boards/${encodeURIComponent(boardId)}/charts/meta`, {
+ params: buildPaginationParams(options),
+ })
+}
+
+charts.command('meta', {
+ description: 'List lightweight chart metadata for a board without query results',
+ options: z.object({
+ boardId: z.string().describe('Board ID to list chart metadata from'),
+ page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'),
+ size: z.coerce.number().optional().describe('Page size (default 100, max 200)'),
+ }),
+ examples: [
+ {
+ options: { boardId: 'board_abc123', size: 25 },
+ description: 'List chart summaries for a board',
+ },
+ ],
+ hint: 'Requires boards:read scope on your API key.',
+ run({ options }) {
+ return listChartSummariesRun(options.boardId, options)
},
})
@@ -61,63 +227,137 @@ charts.command('get', {
},
})
-// ── Create a chart ──
+// ── Query a chart with date variables ──
+
+export interface QueryChartOptions {
+ dateFrom: string
+ dateTo: string
+}
-export function createChartRun(boardId: string, body: string) {
+export function queryChartRun(
+ boardId: string,
+ chartId: string,
+ options: QueryChartOptions,
+) {
requireApiKey()
const client = createClient()
+ return client.get(
+ `/v0/boards/${encodeURIComponent(boardId)}/charts/${encodeURIComponent(chartId)}/query`,
+ { params: { dateFrom: options.dateFrom, dateTo: options.dateTo } },
+ )
+}
- let parsed: unknown
- try {
- parsed = JSON.parse(body)
- } catch {
- throw new Error('--body must be valid JSON')
- }
+charts.command('query', {
+ description: 'Execute a saved chart query after substituting date variables',
+ args: z.object({
+ chartId: z.string().describe('Chart ID to query'),
+ }),
+ options: z.object({
+ boardId: z.string().describe('Board ID the chart belongs to'),
+ dateFrom: z.string().describe('Date variable value for {{date_from}}, YYYY-MM-DD'),
+ dateTo: z.string().describe('Date variable value for {{date_to}}, YYYY-MM-DD'),
+ }),
+ examples: [
+ {
+ args: { chartId: 'chart_abc123' },
+ options: {
+ boardId: 'board_abc123',
+ dateFrom: '2026-04-01',
+ dateTo: '2026-04-30',
+ },
+ description: 'Query a chart for April 2026',
+ },
+ ],
+ hint: 'Requires boards:read scope and a chart query containing {{date_from}}/{{date_to}} variables.',
+ run({ args, options }) {
+ return queryChartRun(options.boardId, args.chartId, options)
+ },
+})
+
+// ── Create a chart ──
+export function createChartRun(
+ boardId: string,
+ input: string | ChartBodyOptions,
+) {
+ requireApiKey()
+ const client = createClient()
return client.post(
`/v0/boards/${encodeURIComponent(boardId)}/charts/`,
- parsed,
+ coerceChartBody(input),
)
}
charts.command('create', {
description: 'Create a new chart in a board',
- options: z.object({
+ options: chartBodyOptions.extend({
boardId: z.string().describe('Board ID to add the chart to'),
- body: z.string().describe('JSON string with chart configuration'),
}),
examples: [
{
options: {
boardId: 'board_abc123',
- body: '{"name":"Daily active users","chartType":"line"}',
+ title: 'Daily active users',
+ chartType: 'line',
+ query:
+ 'SELECT toDate(timestamp) AS date, countDistinct(address) AS users FROM events GROUP BY date ORDER BY date',
+ xAxis: 'date',
+ yAxis: 'users',
+ },
+ description: 'Create a line chart from typed flags',
+ },
+ {
+ options: {
+ boardId: 'board_abc123',
+ body: '{"title":"Onboarding Funnel","chart_type":"funnel","query":"SELECT 1","steps":[{"type":"event","event":"page"},{"type":"event","event":"connect"}]}',
},
- description: 'Create a line chart',
+ description: 'Create a chart from raw JSON',
},
],
hint: 'Requires boards:write scope on your API key.',
run({ options }) {
- return createChartRun(options.boardId, options.body)
+ return createChartRun(options.boardId, options)
},
})
// ── Update a chart ──
-export function updateChartRun(boardId: string, chartId: string, body: string) {
+export function updateChartRun(
+ boardId: string,
+ chartId: string,
+ input: string | ChartBodyOptions,
+) {
requireApiKey()
const client = createClient()
+ const updates = coerceChartBody(input)
- let parsed: unknown
- try {
- parsed = JSON.parse(body)
- } catch {
- throw new Error('--body must be valid JSON')
- }
+ return client
+ .get(
+ `/v0/boards/${encodeURIComponent(boardId)}/charts/${encodeURIComponent(chartId)}`,
+ )
+ .then((current: unknown) => {
+ const existing =
+ current && typeof current === 'object'
+ ? (current as Record)
+ : {}
+ const body = {
+ query: existing.query,
+ chart_type: existing.chart_type,
+ title: existing.title,
+ description: existing.description ?? undefined,
+ x_axis: existing.x_axis ?? undefined,
+ y_axis: existing.y_axis ?? undefined,
+ group_by: existing.group_by ?? undefined,
+ steps: existing.steps ?? undefined,
+ settings: existing.settings ?? undefined,
+ ...updates,
+ }
- return client.put(
- `/v0/boards/${encodeURIComponent(boardId)}/charts/${encodeURIComponent(chartId)}`,
- parsed,
- )
+ return client.put(
+ `/v0/boards/${encodeURIComponent(boardId)}/charts/${encodeURIComponent(chartId)}`,
+ body,
+ )
+ })
}
charts.command('update', {
@@ -125,23 +365,128 @@ charts.command('update', {
args: z.object({
chartId: z.string().describe('Chart ID to update'),
}),
- options: z.object({
+ options: chartBodyOptions.extend({
boardId: z.string().describe('Board ID the chart belongs to'),
- body: z.string().describe('JSON string with updated chart configuration'),
}),
examples: [
{
args: { chartId: 'chart_abc123' },
options: {
boardId: 'board_abc123',
- body: '{"name":"Updated chart name"}',
+ title: 'Updated chart name',
},
- description: 'Update a chart',
+ description: 'Update a chart title',
},
],
hint: 'Requires boards:write scope on your API key.',
run({ args, options }) {
- return updateChartRun(options.boardId, args.chartId, options.body)
+ return updateChartRun(options.boardId, args.chartId, options)
+ },
+})
+
+// ── Move a chart ──
+
+export function moveChartRun(
+ boardId: string,
+ chartId: string,
+ targetBoardId: string,
+) {
+ requireApiKey()
+ const client = createClient()
+ return client.put(
+ `/v0/boards/${encodeURIComponent(boardId)}/charts/${encodeURIComponent(chartId)}/move`,
+ { targetBoardId },
+ )
+}
+
+charts.command('move', {
+ description: 'Move a chart to another board',
+ args: z.object({
+ chartId: z.string().describe('Chart ID to move'),
+ }),
+ options: z.object({
+ boardId: z.string().describe('Current board ID'),
+ targetBoardId: z.string().describe('Destination board ID'),
+ }),
+ examples: [
+ {
+ args: { chartId: 'chart_abc123' },
+ options: { boardId: 'board_source', targetBoardId: 'board_target' },
+ description: 'Move a chart between boards',
+ },
+ ],
+ hint: 'Requires boards:write scope on your API key.',
+ run({ args, options }) {
+ return moveChartRun(options.boardId, args.chartId, options.targetBoardId)
+ },
+})
+
+// ── Duplicate a chart ──
+
+export function normalizeDuplicateChartResponse(result: unknown) {
+ return typeof result === 'string' ? { id: result } : result
+}
+
+export function duplicateChartRun(boardId: string, chartId: string) {
+ requireApiKey()
+ const client = createClient()
+ return client.post(
+ `/v0/boards/${encodeURIComponent(boardId)}/charts/${encodeURIComponent(chartId)}/duplicate`,
+ ).then(normalizeDuplicateChartResponse)
+}
+
+charts.command('duplicate', {
+ description: 'Duplicate a chart within its board',
+ args: z.object({
+ chartId: z.string().describe('Chart ID to duplicate'),
+ }),
+ options: z.object({
+ boardId: z.string().describe('Board ID the chart belongs to'),
+ }),
+ examples: [
+ {
+ args: { chartId: 'chart_abc123' },
+ options: { boardId: 'board_abc123' },
+ description: 'Duplicate a chart',
+ },
+ ],
+ hint: 'Requires boards:write scope on your API key.',
+ run({ args, options }) {
+ return duplicateChartRun(options.boardId, args.chartId)
+ },
+})
+
+// ── Reorder charts ──
+
+export function reorderChartsRun(boardId: string, chartIds: string) {
+ requireApiKey()
+ const client = createClient()
+ return client.put(
+ `/v0/boards/${encodeURIComponent(boardId)}/charts/reorder`,
+ { chartIds: parseStringArray(chartIds, '--chart-ids') },
+ )
+}
+
+charts.command('reorder', {
+ description: 'Reorder charts in a board',
+ options: z.object({
+ boardId: z.string().describe('Board ID whose charts should be reordered'),
+ chartIds: z
+ .string()
+ .describe('Chart IDs in desired order, as comma-separated text or JSON array'),
+ }),
+ examples: [
+ {
+ options: {
+ boardId: 'board_abc123',
+ chartIds: 'chart_a,chart_b,chart_c',
+ },
+ description: 'Reorder charts using comma-separated IDs',
+ },
+ ],
+ hint: 'Requires boards:write scope on your API key.',
+ run({ options }) {
+ return reorderChartsRun(options.boardId, options.chartIds)
},
})
diff --git a/src/commands/contracts.ts b/src/commands/contracts.ts
index bd1ae9c..a2254ef 100644
--- a/src/commands/contracts.ts
+++ b/src/commands/contracts.ts
@@ -1,24 +1,106 @@
import { Cli, z } from 'incur'
import { createClient, requireApiKey } from '../lib/client'
+import { parseJsonArray } from '../lib/json'
export const contracts = Cli.create('contracts', {
- description: 'Smart contract commands — register, list, update, and remove tracked contracts',
+ description:
+ 'Smart contract commands — register, list, recommend, update, toggle pipeline inclusion, and remove tracked contracts',
})
+export interface PaginationOptions {
+ page?: number
+ size?: number
+}
+
+function buildPaginationParams(options: PaginationOptions = {}) {
+ const params: Record = {}
+ if (options.page !== undefined) params.page = options.page
+ if (options.size !== undefined) params.size = options.size
+ return params
+}
+
+function parseChain(chain: string | number) {
+ const value = typeof chain === 'number' ? chain : Number(chain)
+ if (!Number.isInteger(value) || value < 1) {
+ throw new Error('chain must be a positive integer')
+ }
+ return value
+}
+
// ── List contracts ──
-export function listContractsRun() {
+export function listContractsRun(options: PaginationOptions = {}) {
requireApiKey()
const client = createClient()
- return client.get('/v0/contracts/')
+ return client.get('/v0/contracts/', {
+ params: buildPaginationParams(options),
+ })
}
contracts.command('list', {
description: 'List all tracked contracts for the project',
+ options: z.object({
+ page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'),
+ size: z.coerce.number().optional().describe('Page size (default 100, max 200)'),
+ }),
examples: [{ description: 'List all project contracts' }],
hint: 'Requires contracts:read scope on your API key.',
+ run({ options }) {
+ return listContractsRun(options)
+ },
+})
+
+// ── Get a contract ──
+
+export function getContractRun(chain: string, address: string) {
+ requireApiKey()
+ const client = createClient()
+ return client.get(
+ `/v0/contracts/${encodeURIComponent(chain)}/${encodeURIComponent(address)}`,
+ )
+}
+
+// ── Recommended contracts ──
+
+export function getContractRecommendationsRun() {
+ requireApiKey()
+ const client = createClient()
+ return client.get('/v0/contracts/recommendations')
+}
+
+contracts.command('recommendations', {
+ description:
+ 'List contracts the project already interacts with but has not added yet',
+ options: z.object({}),
+ examples: [
+ {
+ description: 'Show recommended contracts to add for decoding/monitoring',
+ },
+ ],
+ hint: 'Requires contracts:read scope on your API key.',
run() {
- return listContractsRun()
+ return getContractRecommendationsRun()
+ },
+})
+
+contracts.command('get', {
+ description: 'Get a tracked contract by chain and address',
+ args: z.object({
+ chain: z.string().describe('Chain ID'),
+ address: z.string().describe('Contract address (0x...)'),
+ }),
+ examples: [
+ {
+ args: {
+ chain: '1',
+ address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
+ description: 'Get a tracked USDC contract',
+ },
+ ],
+ hint: 'Requires contracts:read scope on your API key.',
+ run({ args }) {
+ return getContractRun(args.chain, args.address)
},
})
@@ -30,30 +112,25 @@ export interface CreateContractOptions {
name: string
abi: string
events: string
+ startBlock?: number
+ includeInPipeline?: boolean
}
export function buildCreateContractBody(options: CreateContractOptions) {
- let parsedAbi: unknown
- try {
- parsedAbi = JSON.parse(options.abi)
- } catch {
- throw new Error('--abi must be a valid JSON array')
- }
-
- let parsedEvents: unknown
- try {
- parsedEvents = JSON.parse(options.events)
- } catch {
- throw new Error('--events must be valid JSON')
- }
-
- return {
+ const parsedAbi = parseJsonArray(options.abi, '--abi')
+ const parsedEvents = parseJsonArray(options.events, '--events')
+ const body: Record = {
address: options.address,
- chain: options.chain,
+ chain: parseChain(options.chain),
name: options.name,
- abi: parsedAbi,
+ abi: JSON.stringify(parsedAbi),
events: parsedEvents,
}
+ if (options.startBlock !== undefined) body.start_block = options.startBlock
+ if (options.includeInPipeline !== undefined) {
+ body.include_in_pipeline = options.includeInPipeline
+ }
+ return body
}
export function createContractRun(options: CreateContractOptions) {
@@ -65,20 +142,25 @@ export function createContractRun(options: CreateContractOptions) {
contracts.command('create', {
description: 'Register a new smart contract to track',
options: z.object({
- address: z.string().describe('Contract address (0x…)'),
+ address: z.string().describe('Contract address (0x...)'),
chain: z.coerce.number().describe('Chain ID (e.g. 1 for Ethereum, 137 for Polygon)'),
name: z.string().describe('Human-readable contract name'),
abi: z.string().describe('Contract ABI as a JSON string'),
- events: z.string().describe('Events configuration as a JSON string'),
+ events: z.string().describe('JSON array of ABI event objects to monitor (max 10)'),
+ startBlock: z.coerce.number().optional().describe('Optional start block'),
+ includeInPipeline: z
+ .boolean()
+ .optional()
+ .describe('Whether to include this contract in the Goldsky events pipeline'),
}),
examples: [
{
options: {
- address: '0x1234…',
+ address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
chain: 1,
- name: 'My Token',
- abi: '[{"type":"event","name":"Transfer"}]',
- events: '{"Transfer":true}',
+ name: 'USDC',
+ abi: '[{"anonymous":false,"type":"event","name":"Transfer","inputs":[]}]',
+ events: '[{"anonymous":false,"type":"event","name":"Transfer","inputs":[]}]',
},
description: 'Register an ERC-20 contract on Ethereum',
},
@@ -95,28 +177,29 @@ export interface UpdateContractOptions {
name: string
abi: string
events: string
+ startBlock?: number
+ includeInPipeline?: boolean
}
-export function buildUpdateContractBody(options: UpdateContractOptions) {
- let parsedAbi: unknown
- try {
- parsedAbi = JSON.parse(options.abi)
- } catch {
- throw new Error('--abi must be a valid JSON array')
- }
-
- let parsedEvents: unknown
- try {
- parsedEvents = JSON.parse(options.events)
- } catch {
- throw new Error('--events must be valid JSON')
- }
-
- return {
+export function buildUpdateContractBody(
+ chain: string | number,
+ address: string,
+ options: UpdateContractOptions,
+) {
+ const parsedAbi = parseJsonArray(options.abi, '--abi')
+ const parsedEvents = parseJsonArray(options.events, '--events')
+ const body: Record = {
+ address,
+ chain: parseChain(chain),
name: options.name,
- abi: parsedAbi,
+ abi: JSON.stringify(parsedAbi),
events: parsedEvents,
}
+ if (options.startBlock !== undefined) body.start_block = options.startBlock
+ if (options.includeInPipeline !== undefined) {
+ body.include_in_pipeline = options.includeInPipeline
+ }
+ return body
}
export function updateContractRun(
@@ -128,7 +211,7 @@ export function updateContractRun(
const client = createClient()
return client.put(
`/v0/contracts/${encodeURIComponent(chain)}/${encodeURIComponent(address)}`,
- buildUpdateContractBody(options),
+ buildUpdateContractBody(chain, address, options),
)
}
@@ -136,20 +219,28 @@ contracts.command('update', {
description: 'Update a tracked contract',
args: z.object({
chain: z.string().describe('Chain ID'),
- address: z.string().describe('Contract address (0x…)'),
+ address: z.string().describe('Contract address (0x...)'),
}),
options: z.object({
name: z.string().describe('Updated contract name'),
abi: z.string().describe('Updated ABI as a JSON string'),
- events: z.string().describe('Updated events configuration as a JSON string'),
+ events: z.string().describe('Updated JSON array of ABI event objects to monitor'),
+ startBlock: z.coerce.number().optional().describe('Optional start block'),
+ includeInPipeline: z
+ .boolean()
+ .optional()
+ .describe('Whether to include this contract in the Goldsky events pipeline'),
}),
examples: [
{
- args: { chain: '1', address: '0x1234…' },
+ args: {
+ chain: '1',
+ address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
options: {
- name: 'Renamed Token',
- abi: '[{"type":"event","name":"Transfer"}]',
- events: '{"Transfer":true}',
+ name: 'USD Coin',
+ abi: '[{"anonymous":false,"type":"event","name":"Transfer","inputs":[]}]',
+ events: '[{"anonymous":false,"type":"event","name":"Transfer","inputs":[]}]',
},
description: 'Update a contract',
},
@@ -160,6 +251,57 @@ contracts.command('update', {
},
})
+// ── Toggle contract pipeline inclusion ──
+
+export function updateContractPipelineRun(
+ chain: string,
+ address: string,
+ includeInPipeline: boolean,
+) {
+ requireApiKey()
+ const client = createClient()
+ return client.patch(
+ `/v0/contracts/${encodeURIComponent(chain)}/${encodeURIComponent(address)}/pipeline`,
+ buildUpdateContractPipelineBody(includeInPipeline),
+ )
+}
+
+export function buildUpdateContractPipelineBody(includeInPipeline: boolean) {
+ return { include_in_pipeline: includeInPipeline }
+}
+
+contracts.command('pipeline', {
+ description:
+ 'Toggle whether a tracked contract is included in the project events pipeline',
+ args: z.object({
+ chain: z.string().describe('Chain ID'),
+ address: z.string().describe('Contract address (0x...)'),
+ }),
+ options: z.object({
+ includeInPipeline: z
+ .boolean()
+ .describe('true to include the contract in the pipeline, false to exclude it'),
+ }),
+ examples: [
+ {
+ args: {
+ chain: '1',
+ address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
+ options: { includeInPipeline: false },
+ description: 'Keep ABI decoding but exclude this contract from pipeline deploys',
+ },
+ ],
+ hint: 'Requires contracts:write scope on your API key.',
+ run({ args, options }) {
+ return updateContractPipelineRun(
+ args.chain,
+ args.address,
+ options.includeInPipeline,
+ )
+ },
+})
+
// ── Delete a contract ──
export function deleteContractRun(chain: string, address: string) {
@@ -174,11 +316,14 @@ contracts.command('delete', {
description: 'Remove a tracked contract',
args: z.object({
chain: z.string().describe('Chain ID'),
- address: z.string().describe('Contract address (0x…)'),
+ address: z.string().describe('Contract address (0x...)'),
}),
examples: [
{
- args: { chain: '1', address: '0x1234…' },
+ args: {
+ chain: '1',
+ address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
description: 'Delete a contract',
},
],
diff --git a/src/commands/events.ts b/src/commands/events.ts
new file mode 100644
index 0000000..a84958c
--- /dev/null
+++ b/src/commands/events.ts
@@ -0,0 +1,75 @@
+import { Cli, z } from 'incur'
+import { createEventsClient } from '../lib/client'
+import { parseJsonArrayOfObjects, parseJsonObject } from '../lib/json'
+
+export const events = Cli.create('events', {
+ description: 'Event ingestion commands — send raw analytics events with a project SDK write key',
+})
+
+export interface IngestEventsOptions {
+ events?: string
+ event?: string
+ writeKey?: string
+}
+
+function getWriteKey(options: IngestEventsOptions) {
+ return options.writeKey ?? process.env.FORMO_WRITE_KEY
+}
+
+export function buildIngestEventsBody(options: IngestEventsOptions) {
+ if (options.events) {
+ const events = parseJsonArrayOfObjects(options.events, '--events')
+ if (events.length === 0) {
+ throw new Error('--events must contain at least one event')
+ }
+ return events
+ }
+
+ if (options.event) {
+ return [parseJsonObject(options.event, '--event')]
+ }
+
+ throw new Error('Provide --event or --events')
+}
+
+export function ingestEventsRun(options: IngestEventsOptions) {
+ const writeKey = getWriteKey(options)
+ if (!writeKey) {
+ throw new Error(
+ 'No event write key configured. Pass --write-key or set FORMO_WRITE_KEY.',
+ )
+ }
+ const client = createEventsClient(writeKey)
+ return client.post('/v0/raw_events', buildIngestEventsBody(options))
+}
+
+events.command('ingest', {
+ description: 'Send one or more raw events to the Formo events API',
+ options: z.object({
+ event: z
+ .string()
+ .optional()
+ .describe('Single event as a JSON object; wrapped in an array before sending'),
+ events: z
+ .string()
+ .optional()
+ .describe('JSON array of event objects to send'),
+ writeKey: z
+ .string()
+ .optional()
+ .describe('Project SDK write key. Defaults to FORMO_WRITE_KEY.'),
+ }),
+ examples: [
+ {
+ options: {
+ event:
+ '{"type":"track","channel":"cli","version":"1","anonymous_id":"anon_123","address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","event":"CLI Test","context":{},"properties":{},"original_timestamp":"2026-04-27T23:05:38.000Z","sent_at":"2026-04-27T23:05:42.000Z","message_id":"cli-test-1"}',
+ },
+ description: 'Send one custom event using FORMO_WRITE_KEY',
+ },
+ ],
+ hint: 'Uses the events.formo.so API and requires a project SDK write key, not a workspace API key.',
+ run({ options }) {
+ return ingestEventsRun(options)
+ },
+})
diff --git a/src/commands/import.ts b/src/commands/import.ts
index 83a6cc8..abf2619 100644
--- a/src/commands/import.ts
+++ b/src/commands/import.ts
@@ -1,5 +1,6 @@
import { Cli, z } from 'incur'
import { createClient, requireApiKey } from '../lib/client'
+import { parseJsonArray, parseJsonArrayOfObjects } from '../lib/json'
export const importCmd = Cli.create('import', {
description: 'Import commands — bulk import wallet addresses into your project',
@@ -8,23 +9,35 @@ export const importCmd = Cli.create('import', {
// ── Import wallets ──
export interface ImportWalletsOptions {
- addresses: string
- writeKey: string
+ addresses?: string
+ rows?: string
+ writeKey?: string
}
export function buildImportBody(options: ImportWalletsOptions) {
- let parsedAddresses: unknown
- try {
- parsedAddresses = JSON.parse(options.addresses)
- if (!Array.isArray(parsedAddresses)) {
- throw new Error('not an array')
+ if (options.rows) {
+ const rows = parseJsonArrayOfObjects(options.rows, '--rows')
+ const addresses = rows.map((row) => row.address)
+ if (addresses.some((address) => typeof address !== 'string' || !address)) {
+ throw new Error('--rows entries must each include a non-empty string address')
}
- } catch {
- throw new Error('--addresses must be a valid JSON array of wallet address strings')
+ return {
+ addresses,
+ rows,
+ }
+ }
+
+ if (!options.addresses) {
+ throw new Error('Provide --addresses or --rows')
}
+
+ const addresses = parseJsonArray(options.addresses, '--addresses')
+ if (addresses.some((address) => typeof address !== 'string' || !address)) {
+ throw new Error('--addresses must be a JSON array of wallet address strings')
+ }
+
return {
- addresses: parsedAddresses,
- writeKey: options.writeKey,
+ addresses,
}
}
@@ -39,17 +52,31 @@ importCmd.command('wallets', {
options: z.object({
addresses: z
.string()
+ .optional()
.describe('JSON array of wallet address strings to import'),
- writeKey: z.string().describe('Project write SDK key'),
+ rows: z
+ .string()
+ .optional()
+ .describe('JSON array of {address,properties?} objects for imports with profile properties'),
+ writeKey: z
+ .string()
+ .optional()
+ .describe('Deprecated; import now uses the project write key server-side')
+ .meta({ deprecated: true }),
}),
examples: [
{
options: {
addresses: '["0xabc…","0xdef…"]',
- writeKey: 'write_key_xxx',
},
description: 'Import two wallet addresses',
},
+ {
+ options: {
+ rows: '[{"address":"0xabc…","properties":{"display_name":"Alice"}}]',
+ },
+ description: 'Import wallets with profile properties',
+ },
],
hint: 'Requires profiles:write scope. Only available on Scale and Enterprise plans.',
run({ options }) {
diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts
index 3753c13..d3985f2 100644
--- a/src/commands/profiles.ts
+++ b/src/commands/profiles.ts
@@ -1,15 +1,100 @@
import { Cli, z } from 'incur'
import { createClient, requireApiKey } from '../lib/client'
+import {
+ parseJsonArrayOfObjects,
+ parseJsonObject,
+} from '../lib/json'
export const profiles = Cli.create('profiles', {
description: 'Wallet profile commands',
})
-export function getProfileRun(address: string, expand?: string) {
+export interface LifecycleThresholdOptions {
+ newWindowDays?: number
+ churnWindowDays?: number
+ powerUserMinActiveDays?: number
+ powerUserWindowDays?: number
+ resurrectedGapDays?: number
+ atRiskMinDaysInactive?: number
+ atRiskPriorActiveDaysThreshold?: number
+}
+
+export interface GetProfileOptions extends LifecycleThresholdOptions {
+ expand?: string
+}
+
+function addLifecycleThresholdParams(
+ params: Record,
+ options: LifecycleThresholdOptions,
+) {
+ if (options.newWindowDays !== undefined) {
+ params.new_window_days = options.newWindowDays
+ }
+ if (options.churnWindowDays !== undefined) {
+ params.churn_window_days = options.churnWindowDays
+ }
+ if (options.powerUserMinActiveDays !== undefined) {
+ params.power_user_min_active_days = options.powerUserMinActiveDays
+ }
+ if (options.powerUserWindowDays !== undefined) {
+ params.power_user_window_days = options.powerUserWindowDays
+ }
+ if (options.resurrectedGapDays !== undefined) {
+ params.resurrected_gap_days = options.resurrectedGapDays
+ }
+ if (options.atRiskMinDaysInactive !== undefined) {
+ params.at_risk_min_days_inactive = options.atRiskMinDaysInactive
+ }
+ if (options.atRiskPriorActiveDaysThreshold !== undefined) {
+ params.at_risk_prior_active_days_threshold =
+ options.atRiskPriorActiveDaysThreshold
+ }
+}
+
+const lifecycleThresholdOptions = {
+ newWindowDays: z.coerce
+ .number()
+ .optional()
+ .describe('Override lifecycle new-user window in days'),
+ churnWindowDays: z.coerce
+ .number()
+ .optional()
+ .describe('Override lifecycle churn window in days'),
+ powerUserMinActiveDays: z.coerce
+ .number()
+ .optional()
+ .describe('Override lifecycle power-user minimum active days'),
+ powerUserWindowDays: z.coerce
+ .number()
+ .optional()
+ .describe('Override lifecycle power-user window in days'),
+ resurrectedGapDays: z.coerce
+ .number()
+ .optional()
+ .describe('Override lifecycle resurrected gap in days'),
+ atRiskMinDaysInactive: z.coerce
+ .number()
+ .optional()
+ .describe('Override lifecycle at-risk minimum inactive days'),
+ atRiskPriorActiveDaysThreshold: z.coerce
+ .number()
+ .optional()
+ .describe('Override lifecycle at-risk prior active days threshold'),
+}
+
+export function getProfileRun(
+ address: string,
+ optionsOrExpand: GetProfileOptions | string = {},
+) {
requireApiKey()
const client = createClient()
- const params: Record = {}
- if (expand) params.expand = expand
+ const options =
+ typeof optionsOrExpand === 'string'
+ ? { expand: optionsOrExpand }
+ : optionsOrExpand
+ const params: Record = {}
+ if (options.expand) params.expand = options.expand
+ addLifecycleThresholdParams(params, options)
return client.get(`/v0/profiles/${encodeURIComponent(address)}`, { params })
}
@@ -23,6 +108,7 @@ profiles.command('get', {
.string()
.optional()
.describe('Comma-separated list of fields to expand: apps,chains,tokens,labels'),
+ ...lifecycleThresholdOptions,
}),
examples: [
{ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, description: 'Get a wallet profile' },
@@ -34,12 +120,13 @@ profiles.command('get', {
],
hint: 'Requires profiles:read scope on your API key.',
run({ args, options }) {
- return getProfileRun(args.address, options.expand)
+ return getProfileRun(args.address, options)
},
})
-export interface SearchProfilesOptions {
+export interface SearchProfilesOptions extends LifecycleThresholdOptions {
address?: string
+ search?: string
page?: number
size?: number
orderBy?: string
@@ -107,11 +194,13 @@ export function searchProfilesRun(options: SearchProfilesOptions) {
const params: Record = {}
if (options.address) params.address = options.address
+ if (options.search) params.search = options.search
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
+ addLifecycleThresholdParams(params, options)
let body: object | undefined
if (options.conditions) {
@@ -134,6 +223,7 @@ profiles.command('search', {
description: 'Search wallet profiles with optional filters',
options: z.object({
address: z.string().optional().describe('Filter by wallet address'),
+ search: z.string().optional().describe('Free-text search across address and identity fields'),
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
@@ -172,6 +262,7 @@ profiles.command('search', {
.enum(['and', 'or'])
.optional()
.describe('Logic operator for combining conditions: "and" (default) or "or"'),
+ ...lifecycleThresholdOptions,
}),
examples: [
{ options: { size: 10 }, description: 'List first 10 profiles' },
@@ -220,14 +311,7 @@ export interface UpdateProfileOptions {
}
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')
- }
+ const body = parseJsonObject(options.properties, '--properties')
if (Object.keys(body).length === 0) {
throw new Error('--properties must contain at least one key')
}
@@ -278,6 +362,63 @@ profiles.command('update', {
},
})
+// ── Batch update profile properties ──
+
+export interface BatchUpdateProfilesOptions {
+ rows: string
+}
+
+export function buildBatchUpdateProfilesBody(
+ options: BatchUpdateProfilesOptions,
+) {
+ const rows = parseJsonArrayOfObjects(options.rows, '--rows')
+ if (rows.length === 0) {
+ throw new Error('--rows must contain at least one item')
+ }
+ for (const row of rows) {
+ if (typeof row.address !== 'string' || row.address.length === 0) {
+ throw new Error('--rows entries must each include a non-empty string address')
+ }
+ }
+ return rows
+}
+
+export function batchUpdateProfilesRun(options: BatchUpdateProfilesOptions) {
+ requireApiKey()
+ const client = createClient()
+ return client.post(
+ '/v0/profiles/properties',
+ buildBatchUpdateProfilesBody(options),
+ )
+}
+
+export const profilesProperties = Cli.create('properties', {
+ description: 'Manage first-party profile properties in bulk',
+})
+
+profilesProperties.command('batch', {
+ description: 'Batch update first-party profile properties for up to 100 wallets',
+ options: z.object({
+ rows: z
+ .string()
+ .describe(
+ 'JSON array of flat {address,...properties} objects. ENS names are not resolved in batch requests.',
+ ),
+ }),
+ examples: [
+ {
+ options: {
+ rows: '[{"address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","display_name":"alice.eth","email":"alice@example.com"}]',
+ },
+ description: 'Batch set display names and emails',
+ },
+ ],
+ hint: 'Requires profiles:write scope on your API key. Unknown keys are ignored by the API; invalid rows are quarantined.',
+ run({ options }) {
+ return batchUpdateProfilesRun(options)
+ },
+})
+
// ── Labels sub-resource ──
export const profilesLabels = Cli.create('labels', {
@@ -290,6 +431,8 @@ export interface CreateProfileLabelOptions {
tagId?: string
value?: string
chainId?: string
+ timestamp?: string
+ isDeleted?: boolean
labels?: string
}
@@ -305,9 +448,13 @@ export function buildCreateLabelBody(options: CreateProfileLabelOptions): unknow
}
}
if (options.tagId) {
- const single: Record = { tag_id: options.tagId }
- if (options.value) single.value = options.value
+ const single: Record = { tag_id: options.tagId }
+ if (options.value !== undefined) single.value = options.value
if (options.chainId) single.chain_id = options.chainId
+ if (options.timestamp) single.timestamp = options.timestamp
+ if (options.isDeleted !== undefined) {
+ single._is_deleted = options.isDeleted ? 1 : 0
+ }
return single
}
throw new Error('Provide --tag-id (single label) or --labels (batch JSON array)')
@@ -337,10 +484,18 @@ profilesLabels.command('create', {
.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'),
+ timestamp: z
+ .string()
+ .optional()
+ .describe('Optional historical ISO-8601 timestamp for the label row'),
+ isDeleted: z
+ .boolean()
+ .optional()
+ .describe('Set true with --timestamp to backfill a label removal tombstone'),
labels: z
.string()
.optional()
- .describe('JSON array of UserLabelInput objects for batch upsert'),
+ .describe('JSON array of UserLabelInput objects for this wallet'),
}),
examples: [
{
@@ -353,6 +508,11 @@ profilesLabels.command('create', {
options: { tagId: 'tier', value: 'gold', chainId: '1' },
description: 'Apply a tiered label scoped to a chain',
},
+ {
+ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
+ options: { tagId: 'tier', timestamp: '2024-03-15T00:00:00.000Z', isDeleted: true },
+ description: 'Backfill a historical label removal',
+ },
{
args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
options: { labels: '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]' },
@@ -365,6 +525,64 @@ profilesLabels.command('create', {
},
})
+// ── Batch upsert labels across wallets ──
+
+export interface BatchCreateProfileLabelsOptions {
+ labels: string
+}
+
+export function buildBatchCreateLabelsBody(
+ options: BatchCreateProfileLabelsOptions,
+) {
+ const labels = parseJsonArrayOfObjects(options.labels, '--labels')
+ if (labels.length === 0) {
+ throw new Error('--labels must contain at least one item')
+ }
+ for (const label of labels) {
+ if (typeof label.address !== 'string' || label.address.length === 0) {
+ throw new Error('--labels entries must each include a non-empty string address')
+ }
+ if (typeof label.tag_id !== 'string' || label.tag_id.length === 0) {
+ throw new Error('--labels entries must each include a non-empty string tag_id')
+ }
+ }
+ return labels
+}
+
+export function batchCreateProfileLabelsRun(
+ options: BatchCreateProfileLabelsOptions,
+) {
+ requireApiKey()
+ const client = createClient()
+ return client.post(
+ '/v0/profiles/labels',
+ buildBatchCreateLabelsBody(options),
+ )
+}
+
+profilesLabels.command('batch', {
+ description: 'Batch upsert labels across up to 100 wallets',
+ options: z.object({
+ labels: z
+ .string()
+ .describe(
+ 'JSON array of {address,tag_id,value?,chain_id?,timestamp?,_is_deleted?} objects',
+ ),
+ }),
+ examples: [
+ {
+ options: {
+ labels: '[{"address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","tag_id":"vip","value":"tier-1"}]',
+ },
+ description: 'Batch upsert labels for multiple wallets',
+ },
+ ],
+ hint: 'Requires profiles:write scope on your API key. ENS names are not resolved in batch requests.',
+ run({ options }) {
+ return batchCreateProfileLabelsRun(options)
+ },
+})
+
// ── Delete a profile label ──
export interface DeleteProfileLabelOptions {
@@ -420,4 +638,5 @@ profilesLabels.command('delete', {
},
})
+profiles.command(profilesProperties)
profiles.command(profilesLabels)
diff --git a/src/commands/segments.ts b/src/commands/segments.ts
index 6f25622..d6f3299 100644
--- a/src/commands/segments.ts
+++ b/src/commands/segments.ts
@@ -7,18 +7,34 @@ export const segments = Cli.create('segments', {
// ── List segments ──
-export function listSegmentsRun() {
+export interface PaginationOptions {
+ page?: number
+ size?: number
+}
+
+function buildPaginationParams(options: PaginationOptions = {}) {
+ const params: Record = {}
+ if (options.page !== undefined) params.page = options.page
+ if (options.size !== undefined) params.size = options.size
+ return params
+}
+
+export function listSegmentsRun(options: PaginationOptions = {}) {
requireApiKey()
const client = createClient()
- return client.get('/v0/segments/')
+ return client.get('/v0/segments/', { params: buildPaginationParams(options) })
}
segments.command('list', {
description: 'List all user segments for the project',
+ options: z.object({
+ page: z.coerce.number().optional().describe('Page number (1-indexed, default 1)'),
+ size: z.coerce.number().optional().describe('Page size (default 100, max 200)'),
+ }),
examples: [{ description: 'List all project segments' }],
hint: 'Requires segments:read scope on your API key.',
- run() {
- return listSegmentsRun()
+ run({ options }) {
+ return listSegmentsRun(options)
},
})
diff --git a/src/index.ts b/src/index.ts
index 8ed94f4..e78c625 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,16 +6,17 @@ import { analytics } from "./commands/analytics";
import { boards } from "./commands/boards";
import { charts } from "./commands/charts";
import { contracts } from "./commands/contracts";
+import { events } from "./commands/events";
import { importCmd } from "./commands/import";
import { profiles } from "./commands/profiles";
import { query } from "./commands/query";
import { segments } from "./commands/segments";
+import { getApiBaseUrl } from "./lib/client";
import { clearConfig, getApiKey, readConfig, saveConfig } from "./lib/config";
import { banner, color, error, info, success, warn } from "./lib/ui";
const DASHBOARD_URL = "https://app.formo.so";
const DOCS_URL = "https://docs.formo.so";
-const API_BASE_URL = "https://api.formo.so";
function loginGuide(): string {
return [
@@ -49,7 +50,7 @@ async function validateAndFetchWorkspace(
apiKey: string,
): Promise<{ workspace: string; projectId: string } | null> {
try {
- const res = await fetch(`${API_BASE_URL}/api/validate-api-key`, {
+ const res = await fetch(`${getApiBaseUrl()}/api/validate-api-key`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey }),
@@ -70,7 +71,7 @@ async function validateAndFetchWorkspace(
}
const cli = Cli.create("formo", {
- version: "0.2.0",
+ version: "1.0.1",
description: "Formo API CLI — Web3 analytics from the terminal",
sync: {
suggestions: [
@@ -84,10 +85,15 @@ const cli = Cli.create("formo", {
"list all project alerts",
"create an alert for high-value transactions",
"list charts in a board",
+ "create a line chart in a board",
+ "move or duplicate a dashboard chart",
"list all tracked contracts",
"register a new smart contract",
"list user segments",
"import wallet addresses",
+ "batch update profile properties with profiles properties batch",
+ "batch upsert labels for wallets",
+ "send raw analytics events",
],
},
});
@@ -293,6 +299,7 @@ cli.command(alerts);
cli.command(boards);
cli.command(charts);
cli.command(contracts);
+cli.command(events);
cli.command(segments);
cli.command(importCmd);
diff --git a/src/lib/client.ts b/src/lib/client.ts
index 8bdfb54..8983b7d 100644
--- a/src/lib/client.ts
+++ b/src/lib/client.ts
@@ -1,7 +1,16 @@
import axios, { AxiosError } from 'axios'
import { getApiKey } from './config'
-const BASE_URL = 'https://api.formo.so'
+export const DEFAULT_API_BASE_URL = 'https://api.formo.so'
+export const DEFAULT_EVENTS_BASE_URL = 'https://events.formo.so'
+
+export function getApiBaseUrl() {
+ return process.env.FORMO_API_BASE_URL ?? DEFAULT_API_BASE_URL
+}
+
+export function getEventsBaseUrl() {
+ return process.env.FORMO_EVENTS_BASE_URL ?? DEFAULT_EVENTS_BASE_URL
+}
export interface ApiErrorBody {
error?: {
@@ -37,6 +46,12 @@ export function parseApiError(error: AxiosError): DecoratedApiError {
const parts: string[] = []
parts.push(apiError?.code ? `[${apiError.code}] ${baseMessage}` : baseMessage)
if (apiError?.param) parts.push(`Param: ${apiError.param}`)
+ if (apiError?.details && Object.keys(apiError.details).length > 0) {
+ const details = Object.entries(apiError.details)
+ .map(([key, value]) => `${key}: ${String(value)}`)
+ .join('; ')
+ parts.push(`Details: ${details}`)
+ }
if (apiError?.doc_url) parts.push(`Docs: ${apiError.doc_url}`)
const message = parts.join('\n ')
return Object.assign(new Error(message), {
@@ -49,9 +64,14 @@ export function parseApiError(error: AxiosError): DecoratedApiError {
})
}
-function createClient() {
- const apiKey = getApiKey()
- const baseURL = BASE_URL
+export interface ClientOptions {
+ baseURL?: string
+ apiKey?: string
+}
+
+function createClient(options: ClientOptions = {}) {
+ const apiKey = options.apiKey ?? getApiKey()
+ const baseURL = options.baseURL ?? getApiBaseUrl()
const instance = axios.create({
baseURL,
@@ -72,6 +92,15 @@ function createClient() {
return instance
}
+export function createEventsClient(writeKey: string) {
+ if (!writeKey) {
+ throw new Error(
+ 'No event write key configured. Pass --write-key or set FORMO_WRITE_KEY.',
+ )
+ }
+ return createClient({ baseURL: getEventsBaseUrl(), apiKey: writeKey })
+}
+
export function requireApiKey(): void {
if (!getApiKey()) {
throw new Error(
diff --git a/src/lib/json.ts b/src/lib/json.ts
new file mode 100644
index 0000000..a359325
--- /dev/null
+++ b/src/lib/json.ts
@@ -0,0 +1,61 @@
+export function parseJson(raw: string, flagName: string): unknown {
+ try {
+ return JSON.parse(raw) as unknown
+ } catch {
+ throw new Error(`${flagName} must be valid JSON`)
+ }
+}
+
+export function parseJsonObject(
+ raw: string,
+ flagName: string,
+): Record {
+ const parsed = parseJson(raw, flagName)
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error(`${flagName} must be a valid JSON object`)
+ }
+ return parsed as Record
+}
+
+export function parseJsonArray(raw: string, flagName: string): unknown[] {
+ const parsed = parseJson(raw, flagName)
+ if (!Array.isArray(parsed)) {
+ throw new Error(`${flagName} must be a valid JSON array`)
+ }
+ return parsed
+}
+
+export function parseJsonArrayOfObjects(
+ raw: string,
+ flagName: string,
+): Record[] {
+ const parsed = parseJsonArray(raw, flagName)
+ if (
+ parsed.some(
+ (item) => !item || typeof item !== 'object' || Array.isArray(item),
+ )
+ ) {
+ throw new Error(`${flagName} must be a valid JSON array of objects`)
+ }
+ return parsed as Record[]
+}
+
+export function parseStringArray(raw: string, flagName: string): string[] {
+ const value = raw.trim()
+ if (value.startsWith('[')) {
+ const parsed = parseJsonArray(value, flagName)
+ if (parsed.some((item) => typeof item !== 'string')) {
+ throw new Error(`${flagName} must be a JSON array of strings`)
+ }
+ return parsed as string[]
+ }
+
+ const parts = value
+ .split(',')
+ .map((part) => part.trim())
+ .filter(Boolean)
+ if (parts.length === 0) {
+ throw new Error(`${flagName} must contain at least one value`)
+ }
+ return parts
+}
diff --git a/test/commands/bodyBuilders.test.ts b/test/commands/bodyBuilders.test.ts
index 2e71fa6..29c39fb 100644
--- a/test/commands/bodyBuilders.test.ts
+++ b/test/commands/bodyBuilders.test.ts
@@ -1,11 +1,19 @@
import { expect } from 'chai';
-import { buildAlertBody } from '../../src/commands/alerts';
+import { buildAlertBody, buildTestAlertBody } from '../../src/commands/alerts';
+import { buildBoardBody } from '../../src/commands/boards';
+import {
+ buildChartBody,
+ normalizeDuplicateChartResponse,
+} from '../../src/commands/charts';
import {
buildCreateContractBody,
+ buildUpdateContractPipelineBody,
buildUpdateContractBody,
} from '../../src/commands/contracts';
import { buildImportBody } from '../../src/commands/import';
import {
+ buildBatchCreateLabelsBody,
+ buildBatchUpdateProfilesBody,
buildCreateLabelBody,
buildDeleteLabelBody,
buildUpdateProfileBody,
@@ -19,7 +27,11 @@ describe('commands / body builders', function () {
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' });
+ expect(body).to.deep.equal({
+ name: 'My alert',
+ trigger_type: 'event',
+ trigger_filters: [],
+ });
});
it('parses triggerFilters JSON into trigger_filters', function () {
@@ -63,6 +75,99 @@ describe('commands / body builders', function () {
});
expect(body).to.have.property('secret', '');
});
+
+ it('parses slack property keys JSON into slack_property_keys', function () {
+ const body = buildAlertBody({
+ name: 'x',
+ triggerType: 'event',
+ slackPropertyKeys: '["revenue","chain_id"]',
+ });
+ expect(body.slack_property_keys).to.deep.equal(['revenue', 'chain_id']);
+ });
+ });
+
+ describe('buildTestAlertBody()', function () {
+ it('parses sample objects and recipient overrides', function () {
+ const body = buildTestAlertBody({
+ sampleEvent: '{"event":"transaction"}',
+ sampleUser: '{"address":"0xabc"}',
+ recipientOverrides: '[{"type":"email","value":["a@b.com"]}]',
+ });
+ expect(body).to.deep.equal({
+ sampleEvent: { event: 'transaction' },
+ sampleUser: { address: '0xabc' },
+ recipientOverrides: [{ type: 'email', value: ['a@b.com'] }],
+ });
+ });
+ });
+
+ // ── Boards ──
+
+ describe('buildBoardBody()', function () {
+ it('uses title/isPublic for the current API contract', function () {
+ const body = buildBoardBody({
+ title: 'Weekly KPIs',
+ description: 'Ops board',
+ isPublic: true,
+ });
+ expect(body).to.deep.equal({
+ title: 'Weekly KPIs',
+ description: 'Ops board',
+ isPublic: true,
+ });
+ });
+
+ it('keeps --name as a backwards-compatible alias for --title', function () {
+ expect(buildBoardBody({ name: 'Legacy name' })).to.deep.equal({
+ title: 'Legacy name',
+ });
+ });
+ });
+
+ // ── Charts ──
+
+ describe('buildChartBody()', function () {
+ it('maps typed chart flags to snake_case body fields', function () {
+ const body = buildChartBody({
+ title: 'Daily users',
+ chartType: 'line',
+ query: 'SELECT 1 FORMAT JSON',
+ xAxis: 'date',
+ yAxis: 'users,revenue',
+ groupBy: 'chain',
+ settings: '{"breakdown":"device"}',
+ });
+ expect(body).to.deep.equal({
+ title: 'Daily users',
+ chart_type: 'line',
+ query: 'SELECT 1',
+ x_axis: 'date',
+ y_axis: ['users', 'revenue'],
+ group_by: 'chain',
+ settings: { breakdown: 'device' },
+ });
+ });
+
+ it('merges raw --body with typed flags taking precedence', function () {
+ const body = buildChartBody({
+ body: '{"title":"Old","chart_type":"bar"}',
+ title: 'New',
+ });
+ expect(body).to.deep.equal({ title: 'New', chart_type: 'bar' });
+ });
+ });
+
+ describe('normalizeDuplicateChartResponse()', function () {
+ it('wraps a bare duplicated chart ID', function () {
+ expect(normalizeDuplicateChartResponse('chart_new')).to.deep.equal({
+ id: 'chart_new',
+ });
+ });
+
+ it('leaves object responses intact', function () {
+ const response = { id: 'chart_new', title: 'Copied chart' };
+ expect(normalizeDuplicateChartResponse(response)).to.equal(response);
+ });
});
// ── Contracts ──
@@ -73,29 +178,53 @@ describe('commands / body builders', function () {
address: '0xabc',
chain: 1,
name: 'My Token',
- abi: '[{"type":"event","name":"Transfer"}]',
- events: '{"Transfer":true}',
+ abi: '[{"type":"event","name":"Transfer","anonymous":false,"inputs":[]}]',
+ events: '[{"type":"event","name":"Transfer","anonymous":false,"inputs":[]}]',
+ includeInPipeline: false,
});
expect(body).to.deep.equal({
address: '0xabc',
chain: 1,
name: 'My Token',
- abi: [{ type: 'event', name: 'Transfer' }],
- events: { Transfer: true },
+ abi: '[{"type":"event","name":"Transfer","anonymous":false,"inputs":[]}]',
+ events: [{ type: 'event', name: 'Transfer', anonymous: false, inputs: [] }],
+ include_in_pipeline: false,
});
});
});
describe('buildUpdateContractBody()', function () {
- it('does NOT include address/chain (those are path params, not body)', function () {
- const body = buildUpdateContractBody({
+ it('includes address/chain because the current API validates a full contract body', function () {
+ const body = buildUpdateContractBody('1', '0xabc', {
+ name: 'New Name',
+ abi: '[]',
+ events: '[]',
+ });
+ expect(body).to.deep.equal({
+ address: '0xabc',
+ chain: 1,
+ name: 'New Name',
+ abi: '[]',
+ events: [],
+ });
+ });
+
+ it('includes include_in_pipeline when provided', function () {
+ const body = buildUpdateContractBody('1', '0xabc', {
name: 'New Name',
abi: '[]',
- events: '{}',
+ events: '[]',
+ includeInPipeline: true,
+ });
+ expect(body).to.include({ include_in_pipeline: true });
+ });
+ });
+
+ describe('buildUpdateContractPipelineBody()', function () {
+ it('maps includeInPipeline to include_in_pipeline', function () {
+ expect(buildUpdateContractPipelineBody(false)).to.deep.equal({
+ include_in_pipeline: false,
});
- expect(body).to.deep.equal({ name: 'New Name', abi: [], events: {} });
- expect(body).to.not.have.property('address');
- expect(body).to.not.have.property('chain');
});
});
@@ -117,14 +246,22 @@ describe('commands / body builders', function () {
// ── Import ──
describe('buildImportBody()', function () {
- it('passes parsed addresses array + writeKey through', function () {
+ it('passes parsed addresses array without a writeKey', function () {
const body = buildImportBody({
addresses: '["0xabc","0xdef"]',
- writeKey: 'write_key_xyz',
});
expect(body).to.deep.equal({
addresses: ['0xabc', '0xdef'],
- writeKey: 'write_key_xyz',
+ });
+ });
+
+ it('derives addresses from richer import rows', function () {
+ const body = buildImportBody({
+ rows: '[{"address":"0xabc","properties":{"display_name":"Alice"}}]',
+ });
+ expect(body).to.deep.equal({
+ addresses: ['0xabc'],
+ rows: [{ address: '0xabc', properties: { display_name: 'Alice' } }],
});
});
});
@@ -164,6 +301,19 @@ describe('commands / body builders', function () {
});
});
+ it('includes historical timestamp and tombstone flags', function () {
+ const body = buildCreateLabelBody({
+ tagId: 'tier',
+ timestamp: '2024-03-15T00:00:00.000Z',
+ isDeleted: true,
+ });
+ expect(body).to.deep.equal({
+ tag_id: 'tier',
+ timestamp: '2024-03-15T00:00:00.000Z',
+ _is_deleted: 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"}]',
@@ -183,6 +333,26 @@ describe('commands / body builders', function () {
});
});
+ describe('buildBatchUpdateProfilesBody()', function () {
+ it('accepts profile rows with address and properties', function () {
+ const body = buildBatchUpdateProfilesBody({
+ rows: '[{"address":"0xabc","display_name":"Alice"}]',
+ });
+ expect(body).to.deep.equal([{ address: '0xabc', display_name: 'Alice' }]);
+ });
+ });
+
+ describe('buildBatchCreateLabelsBody()', function () {
+ it('accepts label rows with address and tag_id', function () {
+ const body = buildBatchCreateLabelsBody({
+ labels: '[{"address":"0xabc","tag_id":"vip","value":"gold"}]',
+ });
+ expect(body).to.deep.equal([
+ { address: '0xabc', tag_id: 'vip', value: 'gold' },
+ ]);
+ });
+ });
+
// ── Profiles labels delete ──
describe('buildDeleteLabelBody()', function () {
diff --git a/test/commands/contracts.test.ts b/test/commands/contracts.test.ts
index 86712ca..aff66c3 100644
--- a/test/commands/contracts.test.ts
+++ b/test/commands/contracts.test.ts
@@ -6,7 +6,7 @@ import { requiresLiveApi } from '../helpers/liveApi';
// (bare resource — no envelope).
const TEST_ABI = JSON.stringify([{ type: 'event', name: 'Transfer', inputs: [] }]);
-const TEST_EVENTS = JSON.stringify({ Transfer: true });
+const TEST_EVENTS = JSON.stringify([{ type: 'event', name: 'Transfer', inputs: [] }]);
describe('commands/contracts', function () {
describe('listContractsRun()', function () {
diff --git a/test/commands/events.test.ts b/test/commands/events.test.ts
new file mode 100644
index 0000000..09fd65a
--- /dev/null
+++ b/test/commands/events.test.ts
@@ -0,0 +1,38 @@
+import { expect } from 'chai';
+import { buildIngestEventsBody, ingestEventsRun } from '../../src/commands/events';
+
+describe('commands/events', function () {
+ describe('buildIngestEventsBody()', function () {
+ it('wraps a single --event object in an array', function () {
+ const body = buildIngestEventsBody({
+ event: '{"type":"track","event":"CLI Test"}',
+ });
+ expect(body).to.deep.equal([{ type: 'track', event: 'CLI Test' }]);
+ });
+
+ it('accepts a non-empty --events array', function () {
+ const body = buildIngestEventsBody({
+ events: '[{"type":"track","event":"A"},{"type":"track","event":"B"}]',
+ });
+ expect(body).to.have.length(2);
+ });
+
+ it('throws on invalid event JSON', function () {
+ expect(() => buildIngestEventsBody({ event: 'not-json' })).to.throw(/event/);
+ });
+ });
+
+ describe('ingestEventsRun() — local validation', function () {
+ it('throws when no write key is configured', function () {
+ const saved = process.env.FORMO_WRITE_KEY;
+ delete process.env.FORMO_WRITE_KEY;
+ try {
+ expect(() =>
+ ingestEventsRun({ event: '{"type":"track","event":"CLI Test"}' }),
+ ).to.throw(/write key/i);
+ } finally {
+ if (saved !== undefined) process.env.FORMO_WRITE_KEY = saved;
+ }
+ });
+ });
+});
diff --git a/test/commands/import.test.ts b/test/commands/import.test.ts
index 823b9fa..20d5e65 100644
--- a/test/commands/import.test.ts
+++ b/test/commands/import.test.ts
@@ -7,7 +7,6 @@ describe('commands/import', function () {
expect(() =>
importWalletsRun({
addresses: 'not-json',
- writeKey: 'write_key_test',
}),
).to.throw(/addresses/);
});
@@ -16,7 +15,6 @@ describe('commands/import', function () {
expect(() =>
importWalletsRun({
addresses: '"just-a-string"',
- writeKey: 'write_key_test',
}),
).to.throw(/addresses/);
});
@@ -25,21 +23,26 @@ describe('commands/import', function () {
expect(() =>
importWalletsRun({
addresses: '{"address":"0xabc"}',
- writeKey: 'write_key_test',
}),
).to.throw(/addresses/);
});
+
+ it('throws when rows entries do not include an address', function () {
+ expect(() =>
+ importWalletsRun({
+ rows: '[{"properties":{"display_name":"Alice"}}]',
+ }),
+ ).to.throw(/address/);
+ });
});
describe('importWalletsRun() — API call', function () {
- it('imports wallets when WRITE_KEY is provided', async function () {
- const writeKey = process.env.TEST_WRITE_KEY;
- if (!writeKey) {
- this.skip(); // Set TEST_WRITE_KEY in .env to run this test
+ it('imports wallets when explicitly enabled', async function () {
+ if (process.env.TEST_IMPORT_WALLETS !== '1') {
+ this.skip(); // Set TEST_IMPORT_WALLETS=1 to run this plan-gated mutation
}
const result = await importWalletsRun({
addresses: JSON.stringify(['0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045']),
- writeKey,
}) as unknown;
expect(result).to.exist;
});
diff --git a/test/commands/profiles.test.ts b/test/commands/profiles.test.ts
index 04e27a5..0f7ffb8 100644
--- a/test/commands/profiles.test.ts
+++ b/test/commands/profiles.test.ts
@@ -1,5 +1,7 @@
import { expect } from 'chai';
import {
+ batchCreateProfileLabelsRun,
+ batchUpdateProfilesRun,
createProfileLabelRun,
deleteProfileLabelRun,
getProfileRun,
@@ -95,6 +97,30 @@ describe('commands/profiles', function () {
});
});
+ describe('batchUpdateProfilesRun() — local validation', function () {
+ it('throws on invalid rows JSON', function () {
+ expect(() => batchUpdateProfilesRun({ rows: 'not-json' })).to.throw(/rows/);
+ });
+
+ it('throws when a row is missing address', function () {
+ expect(() =>
+ batchUpdateProfilesRun({ rows: '[{"display_name":"Alice"}]' }),
+ ).to.throw(/address/);
+ });
+ });
+
+ describe('batchCreateProfileLabelsRun() — local validation', function () {
+ it('throws on invalid labels JSON', function () {
+ expect(() => batchCreateProfileLabelsRun({ labels: 'not-json' })).to.throw(/labels/);
+ });
+
+ it('throws when a row is missing tag_id', function () {
+ expect(() =>
+ batchCreateProfileLabelsRun({ labels: '[{"address":"0xabc"}]' }),
+ ).to.throw(/tag_id/);
+ });
+ });
+
describe('deleteProfileLabelRun() — local validation', function () {
it('throws when --tag-id is missing', function () {
expect(() =>
diff --git a/test/lib/client.test.ts b/test/lib/client.test.ts
index ca315a7..0b84920 100644
--- a/test/lib/client.test.ts
+++ b/test/lib/client.test.ts
@@ -2,7 +2,20 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { expect } from 'chai';
-import { createClient, requireApiKey } from '../../src/lib/client';
+import {
+ createClient,
+ getApiBaseUrl,
+ getEventsBaseUrl,
+ requireApiKey,
+} from '../../src/lib/client';
+
+function restoreEnv(name: string, value: string | undefined) {
+ if (value === undefined) {
+ delete process.env[name];
+ } else {
+ process.env[name] = value;
+ }
+}
describe('lib/client', function () {
describe('requireApiKey()', function () {
@@ -40,8 +53,26 @@ describe('lib/client', function () {
});
it('uses https://api.formo.so as base URL', function () {
+ const saved = process.env.FORMO_API_BASE_URL;
+ delete process.env.FORMO_API_BASE_URL;
const client = createClient();
expect(client.defaults.baseURL).to.equal('https://api.formo.so');
+ restoreEnv('FORMO_API_BASE_URL', saved);
+ });
+
+ it('uses FORMO_API_BASE_URL when set', function () {
+ const saved = process.env.FORMO_API_BASE_URL;
+ process.env.FORMO_API_BASE_URL = 'http://localhost:3001';
+ expect(getApiBaseUrl()).to.equal('http://localhost:3001');
+ expect(createClient().defaults.baseURL).to.equal('http://localhost:3001');
+ restoreEnv('FORMO_API_BASE_URL', saved);
+ });
+
+ it('uses FORMO_EVENTS_BASE_URL when set', function () {
+ const saved = process.env.FORMO_EVENTS_BASE_URL;
+ process.env.FORMO_EVENTS_BASE_URL = 'http://localhost:3002';
+ expect(getEventsBaseUrl()).to.equal('http://localhost:3002');
+ restoreEnv('FORMO_EVENTS_BASE_URL', saved);
});
it('sets a 30 second timeout', function () {
diff --git a/test/lib/parseApiError.test.ts b/test/lib/parseApiError.test.ts
index 8950820..db33aad 100644
--- a/test/lib/parseApiError.test.ts
+++ b/test/lib/parseApiError.test.ts
@@ -90,6 +90,10 @@ describe('lib/client / parseApiError', function () {
expect(err.code).to.equal('INVALID_VALIDATION_REQUEST');
expect(err.details).to.deep.equal(details);
+ expect(err.message).to.include('Details: body.name: Required');
+ expect(err.message).to.include(
+ 'body.conditions.0.operator: Expected one of: gt, lt, eq',
+ );
});
it('falls back to axios message when the body has no error envelope', function () {