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 <desc>] [--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 <boardId> [--name <name>] [--description <desc>] +formo boards update <boardId> [--title <title>] [--description <desc>] [--is-public] ``` > Requires `boards:write` scope. @@ -345,6 +377,14 @@ formo charts list --board-id <boardId> > Requires `boards:read` scope. +### List chart metadata + +```bash +formo charts meta --board-id <boardId> +``` + +> Requires `boards:read` scope. + ### Get a single chart ```bash @@ -356,20 +396,27 @@ formo charts get <chartId> --board-id <boardId> ### Create a chart ```bash -formo charts create --board-id <boardId> --body '<json>' +formo charts create --board-id <boardId> [--body '<json>' | 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 <chartId> --board-id <boardId> --body '<json>' > Requires `boards:write` scope. +### Query, move, duplicate, or reorder charts + +```bash +formo charts query <chartId> --board-id <boardId> --date-from 2026-04-01 --date-to 2026-04-30 +formo charts move <chartId> --board-id <sourceBoardId> --target-board-id <targetBoardId> +formo charts duplicate <chartId> --board-id <boardId> +formo charts reorder --board-id <boardId> --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 <chain> <address> +``` + +> 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 <addr> --chain <chainId> --name <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 <chain> <address> --name <name> --abi '<json>' --events ' > Requires `contracts:write` scope. +### Toggle pipeline inclusion + +```bash +formo contracts pipeline <chain> <address> --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 <segmentId> Bulk import wallet addresses into a project to track them. This creates identify events for each address. ```bash -formo import wallets --addresses '<json>' --write-key <writeKey> +formo import wallets --addresses '<json>' +formo import wallets --rows '<json>' ``` | 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<string, number> = {} + 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<string, unknown> = { 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<string, unknown> = {} + 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<string, number> = {} + 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<string, unknown> = {} + 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<string, unknown> = { 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<string, unknown> = {} - 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<string, number> = {} + 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<string, unknown> = {} + + 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<string, unknown>) + : {} + 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<string, number> = {} + 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<string, unknown> = { 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<string, unknown> = { + 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<string, string | number>, + 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<string, string> = {} - if (expand) params.expand = expand + const options = + typeof optionsOrExpand === 'string' + ? { expand: optionsOrExpand } + : optionsOrExpand + const params: Record<string, string | number> = {} + 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<string, string | number> = {} 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<string, unknown> - 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<string, string> = { tag_id: options.tagId } - if (options.value) single.value = options.value + const single: Record<string, string | number> = { 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<string, number> = {} + 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<string, unknown> { + 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<string, unknown> +} + +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<string, unknown>[] { + 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<string, unknown>[] +} + +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 () {