@formo/cli 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -104,23 +104,38 @@ Search wallet profiles with filters, sorting, and pagination. Returns a `Paginat
104
104
  | Option | Description |
105
105
  |---|---|
106
106
  | `--address` | Filter by wallet address |
107
+ | `--search` | Free-text search across address and identity fields |
107
108
  | `--page` | Page number (1-indexed, default `1`) |
108
109
  | `--size` | Page size (default `100`, max `1000`) |
109
110
  | `--order-by` | `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` |
110
111
  | `--order-dir` | `asc` or `desc` |
111
112
  | `--expand` | Comma-separated fields to expand |
112
- | `--conditions` | JSON array of `FilterCondition` objects (see below) |
113
- | `--logic` | Combine conditions with `and` (default) or `or` |
113
+ | `--filters` | JSON array of canonical `{field,op,value}` filter objects (see below) |
114
+ | `--logic` | Combine filters with `and` (default) or `or` |
114
115
 
115
116
  ```bash
116
117
  formo profiles search --size 10
117
118
  formo profiles search --order-by net_worth_usd --order-dir desc --size 5
118
119
  formo profiles search --page 2 --size 20
119
- formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20
120
- formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]' --logic or --size 20
121
- formo profiles search --conditions '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20
120
+ formo profiles search --filters '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20
121
+ formo profiles search --filters '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]' --logic or --size 20
122
+ formo profiles search --filters '[{"field":"chains.balance","op":"gt","value":1000,"chain_id":"1"}]' --size 20
122
123
  ```
123
124
 
125
+ ### Lifecycle tuning (advanced)
126
+
127
+ Both `profiles get` and `profiles search` accept optional flags to override the lifecycle stage thresholds used when computing `lifecycle`:
128
+
129
+ | Option | Description |
130
+ |---|---|
131
+ | `--new-window-days` | Override lifecycle new-user window in days |
132
+ | `--churn-window-days` | Override lifecycle churn window in days |
133
+ | `--power-user-min-active-days` | Override lifecycle power-user minimum active days |
134
+ | `--power-user-window-days` | Override lifecycle power-user window in days |
135
+ | `--resurrected-gap-days` | Override lifecycle resurrected gap in days |
136
+ | `--at-risk-min-days-inactive` | Override lifecycle at-risk minimum inactive days |
137
+ | `--at-risk-prior-active-days-threshold` | Override lifecycle at-risk prior active days threshold |
138
+
124
139
  ### `profiles update <address>`
125
140
 
126
141
  Merge-update identity properties on a wallet profile.
@@ -205,15 +220,16 @@ Get a single alert by ID.
205
220
  | `--trigger-filters` | JSON array of trigger filter objects |
206
221
  | `--recipient` | JSON array of recipient objects |
207
222
  | `--secret` | Webhook secret |
223
+ | `--slack-property-keys` | JSON array of event/user property keys to include in Slack alerts |
208
224
 
209
225
  ```bash
210
226
  formo alerts create --name "High value tx" --trigger-type event \
211
- --trigger-filters '[{"name":"event","operator":"eq","value":"transaction"}]' \
227
+ --trigger-filters '[{"field":"event","op":"eq","value":"transaction"}]' \
212
228
  --recipient '[{"type":"email","value":["alerts@myapp.com"]}]'
213
229
  ```
214
230
 
215
231
  ### `alerts update <alertId>`
216
- Same options as `create`. Replaces the alert configuration.
232
+ Same options as `create`. Replaces the alert configuration in full — omitted options are reset to their defaults (e.g. leaving out `--trigger-filters` clears the existing trigger filters).
217
233
 
218
234
  ### `alerts delete <alertId>`
219
235
  Delete an alert.
@@ -271,7 +287,7 @@ Delete a board.
271
287
 
272
288
  ## `formo charts`
273
289
 
274
- Chart commands. Charts live inside a board. Requires `charts:read` / `charts:write`.
290
+ Chart commands. Charts live inside a board. Requires `boards:read` / `boards:write`.
275
291
 
276
292
  ### `charts list --board-id <boardId>`
277
293
  List all charts in a board.
@@ -292,10 +308,18 @@ formo charts create --board-id brd_123 --title "Daily Active Users" \
292
308
  --x-axis date --y-axis users
293
309
 
294
310
  formo charts create --board-id brd_123 --body '{"title":"Recent Events","chart_type":"table","query":"SELECT * FROM events LIMIT 10"}'
311
+
312
+ formo charts create --board-id brd_123 --title "Post-connect paths" \
313
+ --chart-type user_paths \
314
+ --settings '{"anchors":[{"type":"event","event":"connect"}],"maxSteps":5,"nodesPerStep":3}'
295
315
  ```
296
316
 
297
- ### `charts update <chartId> --board-id <boardId> --body '<json>'`
298
- Update a chart.
317
+ ### `charts update <chartId> --board-id <boardId> [options]`
318
+ Update a chart. Accepts the same options as `create`: a raw `--body '<json>'` and/or typed flags (`--title`, `--chart-type`, `--query`, `--description`, `--x-axis`, `--y-axis`, `--group-by`, `--steps`, `--settings`). `--steps` is for funnel steps. User Paths use `--settings` with `anchors` and optional `maxSteps` / `nodesPerStep`; retention settings require an `entryFilter` key. Typed flags override matching `--body` keys.
319
+
320
+ ```bash
321
+ formo charts update chart_abc123 --board-id brd_123 --title "Renamed chart"
322
+ ```
299
323
 
300
324
  ### `charts query <chartId> --board-id <boardId> --date-from <YYYY-MM-DD> --date-to <YYYY-MM-DD>`
301
325
  Execute a saved chart that uses `{{date_from}}` / `{{date_to}}` variables.
@@ -369,7 +393,7 @@ List all user segments.
369
393
  | Option | Description |
370
394
  |---|---|
371
395
  | `--title` | Segment title |
372
- | `--filter-sets` | JSON array of filter set strings defining the segment |
396
+ | `--filters` | JSON array of canonical `{field,op,value}` filter objects. Array string members cannot contain `|` |
373
397
 
374
398
  ### `segments delete <segmentId>`
375
399
  Delete a user segment.
@@ -401,7 +425,7 @@ Pre-built analytics pipes — the same data that powers the Formo dashboard —
401
425
  |---|---|
402
426
  | `--date-from` | Inclusive start date `YYYY-MM-DD` (default: 7 days before `--date-to`) |
403
427
  | `--date-to` | Inclusive end date `YYYY-MM-DD` (default: today) |
404
- | `--filters` | JSON array of `[{field,op,value}]`. Use `in`/`nin` with a pipe-delimited value (e.g. `"chrome\|firefox"`) |
428
+ | `--filters` | JSON array of `[{field,op,value}]`. For `in`/`nin`, array values are preferred; pipe-delimited strings are also accepted. Array string members cannot contain `|` |
405
429
  | `--params` | JSON object of pipe-specific params merged into the query (e.g. `{"limit":10,"group_by":"device"}`) |
406
430
 
407
431
  ```bash
@@ -414,13 +438,17 @@ formo analytics retention --filters '[{"field":"location","op":"eq","value":"US"
414
438
 
415
439
  > Requires `query:read` scope. Run `formo analytics <pipe> --help` for the pipe-specific params accepted via `--params`.
416
440
 
441
+ On `kpis`, `top_*`, `revenue_*` and `volume_by_metric`, `--params '{"page_scope":"session"}'` widens a `page` filter from page-scoped metrics (the default) to the legacy session scope.
442
+
443
+ All user-attribute, profile, social, lifecycle and resource predicates go in the single `--filters` array, using the canonical envelope with named qualifiers (`chain_id`, `app_id`, `token_address`, `scope`, `tag_id`). The retired per-family params — `socials`, `chain_filters`, `app_filters`, `token_filters`, `label_filters`, `profile_filters`, `lifecycle_filter` — are rejected with a `400` if passed through `--params`.
444
+
417
445
  ---
418
446
 
419
447
  ## `formo import`
420
448
 
421
449
  ### `import wallets`
422
450
 
423
- Bulk-import wallet addresses into the project via the events API.
451
+ Bulk-import wallet addresses into the project via the main Formo API, authenticated with your workspace API key.
424
452
 
425
453
  | Option | Description |
426
454
  |---|---|
@@ -432,53 +460,86 @@ formo import wallets --addresses '["0xabc...","0xdef..."]'
432
460
  formo import wallets --rows '[{"address":"0xabc...","properties":{"display_name":"Alice"}}]'
433
461
  ```
434
462
 
463
+ > Requires `profiles:write` scope. Only available on Scale and Enterprise plans.
464
+
435
465
  ---
436
466
 
437
467
  ## `formo events`
438
468
 
439
469
  ### `events ingest`
440
470
 
441
- Send raw analytics events to `events.formo.so`. This command uses a project SDK write key, not the workspace API key.
471
+ Send raw analytics events to `events.formo.so`. This command uses a project SDK write key, not the workspace API key — pass it via `--write-key` or the `FORMO_WRITE_KEY` environment variable.
472
+
473
+ | Option | Description |
474
+ |---|---|
475
+ | `--event` | Single event as a JSON object; wrapped in an array before sending |
476
+ | `--events` | JSON array of event objects to send as a batch |
477
+ | `--write-key` | Project SDK write key (defaults to `FORMO_WRITE_KEY`) |
442
478
 
443
479
  ```bash
444
480
  export FORMO_WRITE_KEY=formo_write_key_xxx
445
481
  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"}'
482
+ formo events ingest --events '[{"type":"track","event":"First"},{"type":"track","event":"Second"}]'
446
483
  ```
447
484
 
448
485
  ---
449
486
 
450
487
  ## FilterCondition reference
451
488
 
452
- `profiles search --conditions` accepts a JSON array of filter condition objects:
489
+ `profiles search --filters` accepts a JSON array of canonical filter objects:
453
490
 
454
491
  ```json
455
492
  [
456
493
  { "field": "users.net_worth_usd", "op": "gt", "value": 10000 },
457
- { "field": "chains.1.balance", "op": "gte", "value": 1000 }
494
+ { "field": "chains.balance", "op": "gte", "value": 1000, "chain_id": "1" }
458
495
  ]
459
496
  ```
460
497
 
461
- > **The `field` must be a typed path.** A bare name like `net_worth_usd` is
498
+ > **The `field` must be a canonical path.** A bare name like `net_worth_usd` is
462
499
  > silently ignored by the API (no error, no filtering — the search returns
463
- > everything). Always prefix the field with its type.
500
+ > everything). Resource identity goes in the named qualifiers below, never in
501
+ > the field path — identifier-in-path fields such as `chains.1.balance` are
502
+ > rejected with a `400`.
464
503
 
465
504
  | Field | Type | Description |
466
505
  |---|---|---|
467
- | `field` | `string` | Typed path (see prefixes below) |
468
- | `op` | `string` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains` (social fields only), `notEmpty` / `isEmpty` (value-less existence checks). Long-form spellings (`equals`, `greater`, `includes`, …) are retired — the API rejects them with a `400` naming the token |
469
- | `value` | `any` | Value to compare against |
470
- | `scope` | `string` | _(token filters only)_ `any` or `protocol` |
471
- | `appId` | `string` | _(token filters with `scope: protocol`)_ e.g. `aave-v3` |
506
+ | `field` | `string` | `users.{attribute}` or one of the four resource paths (see below) |
507
+ | `op` | `string` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `startsWith`, `endsWith`, `notEmpty` / `isEmpty` (value-less existence checks). Support is per field — see [operator support](#operator-support). Long-form spellings (`equals`, `greater`, `includes`, …) are retired — the API rejects them with a `400` naming the token |
508
+ | `value` | `any` | Value to compare against; must be a number on the `.balance` fields |
509
+ | `chain_id` | `string` | _(optional on any resource filter)_ restrict to one chain; omit to match any |
510
+ | `app_id` | `string` | _(required by `apps.balance`, and by `tokens.balance` with `scope: protocol`)_ e.g. `aave-v3` |
511
+ | `token_address` | `string` | _(required by `tokens.balance`)_ |
512
+ | `tag_id` | `string` | _(required by `labels.value`)_ e.g. `coinbase.verified_account` |
513
+ | `scope` | `string` | _(required by `tokens.balance`)_ `any` or `protocol` |
514
+
515
+ | Field | Required qualifiers | Example |
516
+ |---|---|---|
517
+ | `users.{attribute}` | none | `{"field":"users.net_worth_usd","op":"gt","value":10000}` |
518
+ | `chains.balance` | none (`chain_id` optional) | `{"field":"chains.balance","op":"gte","value":1000,"chain_id":"1"}` |
519
+ | `apps.balance` | `app_id` | `{"field":"apps.balance","op":"gt","value":500,"app_id":"uniswap-v3"}` |
520
+ | `tokens.balance` | `token_address`, `scope` | `{"field":"tokens.balance","op":"gt","value":0,"token_address":"0xA0b8…48","scope":"any"}` |
521
+ | `labels.value` | `tag_id` | `{"field":"labels.value","op":"eq","value":"true","tag_id":"coinbase.verified_account"}` |
472
522
 
473
- | Prefix | Examples |
474
- |---|---|
475
- | `users.` | `users.net_worth_usd`, `users.volume`, `users.revenue`, `users.points`, `users.device`, `users.location`, `users.lifecycle`, `users.ens`, `users.farcaster` |
476
- | `chains.` | `chains.balance` (any chain), `chains.1.balance` (Ethereum) |
477
- | `apps.` | `apps.uniswap-v3.balance` |
478
- | `tokens.` | `tokens.0xA0b8…48.balance` |
479
- | `labels.` | `labels.coinbase.verified_account` |
523
+ User attributes for `users.{attribute}`: `net_worth_usd`, `volume`, `revenue`,
524
+ `points`, `device`, `location`, `lifecycle`, `ens`, `farcaster`, and the other
525
+ social handles.
526
+
527
+ ### Operator support
480
528
 
481
- Combine multiple conditions with `--logic and` (default) or `--logic or`.
529
+ The canonical vocabulary is shared, but each field implements a subset. The API
530
+ rejects an unsupported pairing with a `400`:
531
+
532
+ | Field class | Supported operators |
533
+ |---|---|
534
+ | `chains.balance`, `apps.balance`, `tokens.balance` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte` (value must be a JSON number) |
535
+ | `labels.value` | comparison operators plus `contains` (case-insensitive) |
536
+ | Numeric profile metrics (`users.net_worth_usd`, `users.volume`, `users.revenue`, `users.points`) | comparison operators |
537
+ | Routable string attributes (`users.device`, `users.os`, `users.referrer`, `users.utm_*`, `users.click_id`, and the `first_*`/`last_*` attribution variants) | full vocabulary; `contains`/`startsWith`/`endsWith` match case-sensitively |
538
+ | Social fields (`users.twitter`, `users.email`, `users.farcaster`, …) | `contains` (case-insensitive) and `notEmpty`; `startsWith`/`endsWith`/`isEmpty` are rejected |
539
+ | `users.paid_source` (and `first_`/`last_` variants) | `eq`, `neq`, `in`, `nin`, `notEmpty`, `isEmpty` — it is a fixed ad-network enum |
540
+ | `users.lifecycle` | `eq` (one stage) and `in` (a list of stages) |
541
+
542
+ Combine multiple filters with `--logic and` (default) or `--logic or`.
482
543
 
483
544
  ---
484
545
 
@@ -1,36 +1,28 @@
1
1
  import { Cli } from 'incur';
2
+ import { type PaginationOptions } from '../lib/pagination';
3
+ export type { PaginationOptions };
2
4
  export declare const alerts: Cli.Cli<{}, undefined, undefined>;
3
- export interface PaginationOptions {
4
- page?: number;
5
- size?: number;
6
- }
7
- export declare function listAlertsRun(options?: PaginationOptions): Promise<import("axios").AxiosResponse<any, any, {}>>;
8
- export declare function getAlertRun(alertId: string): Promise<import("axios").AxiosResponse<any, any, {}>>;
9
- export interface CreateAlertOptions {
10
- name: string;
11
- triggerType: 'event' | 'user' | string;
12
- triggerFilters?: string;
13
- recipient?: string;
14
- secret?: string;
15
- slackPropertyKeys?: string;
16
- }
17
- export declare function buildAlertBody(options: CreateAlertOptions | UpdateAlertOptions): Record<string, unknown>;
18
- export declare function createAlertRun(options: CreateAlertOptions): Promise<import("axios").AxiosResponse<any, any, {}>>;
19
- export interface UpdateAlertOptions {
5
+ export declare function listAlertsRun(options?: PaginationOptions): Promise<unknown>;
6
+ export declare function getAlertRun(alertId: string): Promise<unknown>;
7
+ export interface AlertBodyOptions {
20
8
  name: string;
21
- triggerType: 'event' | 'user' | string;
9
+ triggerType: string;
22
10
  triggerFilters?: string;
23
11
  recipient?: string;
24
12
  secret?: string;
25
13
  slackPropertyKeys?: string;
26
14
  }
27
- export declare function updateAlertRun(alertId: string, options: UpdateAlertOptions): Promise<import("axios").AxiosResponse<any, any, {}>>;
28
- export declare function deleteAlertRun(alertId: string): Promise<import("axios").AxiosResponse<any, any, {}>>;
29
- export declare function toggleAlertRun(alertId: string, status: string): Promise<import("axios").AxiosResponse<any, any, {}>>;
15
+ export type CreateAlertOptions = AlertBodyOptions;
16
+ export type UpdateAlertOptions = AlertBodyOptions;
17
+ export declare function buildAlertBody(options: AlertBodyOptions): Record<string, unknown>;
18
+ export declare function createAlertRun(options: CreateAlertOptions): Promise<unknown>;
19
+ export declare function updateAlertRun(alertId: string, options: AlertBodyOptions): Promise<unknown>;
20
+ export declare function deleteAlertRun(alertId: string): Promise<unknown>;
21
+ export declare function toggleAlertRun(alertId: string, status: string): Promise<unknown>;
30
22
  export interface TestAlertOptions {
31
23
  sampleEvent?: string;
32
24
  sampleUser?: string;
33
25
  recipientOverrides?: string;
34
26
  }
35
27
  export declare function buildTestAlertBody(options: TestAlertOptions): Record<string, unknown> | undefined;
36
- export declare function testAlertRun(alertId: string, options?: TestAlertOptions): Promise<import("axios").AxiosResponse<any, any, {}>>;
28
+ export declare function testAlertRun(alertId: string, options?: TestAlertOptions): Promise<unknown>;
@@ -12,29 +12,21 @@ exports.buildTestAlertBody = buildTestAlertBody;
12
12
  exports.testAlertRun = testAlertRun;
13
13
  const incur_1 = require("incur");
14
14
  const client_1 = require("../lib/client");
15
+ const filters_1 = require("../lib/filters");
15
16
  const json_1 = require("../lib/json");
17
+ const pagination_1 = require("../lib/pagination");
16
18
  exports.alerts = incur_1.Cli.create('alerts', {
17
19
  description: 'Project alert commands — create, list, update, and delete alerts',
18
20
  });
19
- function buildPaginationParams(options = {}) {
20
- const params = {};
21
- if (options.page !== undefined)
22
- params.page = options.page;
23
- if (options.size !== undefined)
24
- params.size = options.size;
25
- return params;
26
- }
21
+ // ── List alerts ──
27
22
  function listAlertsRun(options = {}) {
28
23
  (0, client_1.requireApiKey)();
29
24
  const client = (0, client_1.createClient)();
30
- return client.get('/v0/alerts/', { params: buildPaginationParams(options) });
25
+ return client.get('/v0/alerts/', { params: (0, pagination_1.buildPaginationParams)(options) });
31
26
  }
32
27
  exports.alerts.command('list', {
33
28
  description: 'List all alerts for the project',
34
- options: incur_1.z.object({
35
- page: incur_1.z.coerce.number().optional().describe('Page number (1-indexed, default 1)'),
36
- size: incur_1.z.coerce.number().optional().describe('Page size (default 100, max 200)'),
37
- }),
29
+ options: incur_1.z.object(pagination_1.paginationOptionsSchema),
38
30
  examples: [{ description: 'List all project alerts' }],
39
31
  hint: 'Requires alerts:read scope on your API key.',
40
32
  run({ options }) {
@@ -60,6 +52,24 @@ exports.alerts.command('get', {
60
52
  return getAlertRun(args.alertId);
61
53
  },
62
54
  });
55
+ // Shared option fragment for `create` and `update` (same PUT/POST body).
56
+ const alertBodyOptionsSchema = {
57
+ name: incur_1.z.string().describe('Alert name'),
58
+ triggerType: incur_1.z.enum(['event', 'user']).describe('Trigger type'),
59
+ triggerFilters: incur_1.z
60
+ .string()
61
+ .optional()
62
+ .describe('JSON array of trigger filter objects'),
63
+ recipient: incur_1.z
64
+ .string()
65
+ .optional()
66
+ .describe('JSON array of recipient objects'),
67
+ secret: incur_1.z.string().optional().describe('Webhook secret for the alert'),
68
+ slackPropertyKeys: incur_1.z
69
+ .string()
70
+ .optional()
71
+ .describe('JSON array of event/user property keys to include in Slack alerts'),
72
+ };
63
73
  function buildAlertBody(options) {
64
74
  const body = {
65
75
  name: options.name,
@@ -67,7 +77,27 @@ function buildAlertBody(options) {
67
77
  trigger_filters: [],
68
78
  };
69
79
  if (options.triggerFilters) {
70
- body.trigger_filters = (0, json_1.parseJsonArray)(options.triggerFilters, '--trigger-filters');
80
+ const triggerFilters = (0, json_1.parseJsonArray)(options.triggerFilters, '--trigger-filters');
81
+ for (const filter of triggerFilters) {
82
+ if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
83
+ throw new Error('--trigger-filters must be a JSON array of {field, op, value} objects');
84
+ }
85
+ const record = filter;
86
+ if (typeof record.field !== 'string' ||
87
+ record.field.length === 0 ||
88
+ typeof record.value !== 'string') {
89
+ throw new Error('--trigger-filters: each entry requires string "field" and "value"');
90
+ }
91
+ if (record.op !== undefined &&
92
+ record.op !== '' &&
93
+ !(0, filters_1.isCanonicalFilterOperator)(record.op)) {
94
+ throw new Error('--trigger-filters: "op" must use the canonical filter vocabulary');
95
+ }
96
+ if (Object.keys(record).some((key) => !['field', 'op', 'value', 'numericThreshold'].includes(key))) {
97
+ throw new Error('--trigger-filters entries may only contain field, op, value, and numericThreshold');
98
+ }
99
+ }
100
+ body.trigger_filters = triggerFilters;
71
101
  }
72
102
  if (options.recipient) {
73
103
  body.recipient = (0, json_1.parseJsonArray)(options.recipient, '--recipient');
@@ -87,23 +117,7 @@ function createAlertRun(options) {
87
117
  }
88
118
  exports.alerts.command('create', {
89
119
  description: 'Create a new project alert',
90
- options: incur_1.z.object({
91
- name: incur_1.z.string().describe('Alert name'),
92
- triggerType: incur_1.z.enum(['event', 'user']).describe('Trigger type'),
93
- triggerFilters: incur_1.z
94
- .string()
95
- .optional()
96
- .describe('JSON array of trigger filter objects'),
97
- recipient: incur_1.z
98
- .string()
99
- .optional()
100
- .describe('JSON array of recipient objects'),
101
- secret: incur_1.z.string().optional().describe('Webhook secret for the alert'),
102
- slackPropertyKeys: incur_1.z
103
- .string()
104
- .optional()
105
- .describe('JSON array of event/user property keys to include in Slack alerts'),
106
- }),
120
+ options: incur_1.z.object(alertBodyOptionsSchema),
107
121
  examples: [
108
122
  {
109
123
  options: { name: 'High value tx', triggerType: 'event' },
@@ -115,33 +129,18 @@ exports.alerts.command('create', {
115
129
  return createAlertRun(options);
116
130
  },
117
131
  });
132
+ // ── Update an alert ──
118
133
  function updateAlertRun(alertId, options) {
119
134
  (0, client_1.requireApiKey)();
120
135
  const client = (0, client_1.createClient)();
121
136
  return client.put(`/v0/alerts/${encodeURIComponent(alertId)}`, buildAlertBody(options));
122
137
  }
123
138
  exports.alerts.command('update', {
124
- description: 'Update an existing alert',
139
+ description: 'Update an existing alert (full replace — omitted options reset to defaults)',
125
140
  args: incur_1.z.object({
126
141
  alertId: incur_1.z.string().describe('Alert ID to update'),
127
142
  }),
128
- options: incur_1.z.object({
129
- name: incur_1.z.string().describe('Alert name'),
130
- triggerType: incur_1.z.enum(['event', 'user']).describe('Trigger type'),
131
- triggerFilters: incur_1.z
132
- .string()
133
- .optional()
134
- .describe('JSON array of trigger filter objects'),
135
- recipient: incur_1.z
136
- .string()
137
- .optional()
138
- .describe('JSON array of recipient objects'),
139
- secret: incur_1.z.string().optional().describe('Webhook secret for the alert'),
140
- slackPropertyKeys: incur_1.z
141
- .string()
142
- .optional()
143
- .describe('JSON array of event/user property keys to include in Slack alerts'),
144
- }),
143
+ options: incur_1.z.object(alertBodyOptionsSchema),
145
144
  examples: [
146
145
  {
147
146
  args: { alertId: 'alert_abc123' },
@@ -22,4 +22,4 @@ export interface AnalyticsOptions {
22
22
  * Exported for unit testing.
23
23
  */
24
24
  export declare function buildAnalyticsParams(options: AnalyticsOptions): Record<string, string | number | boolean>;
25
- export declare function runAnalytics(pipe: string, options: AnalyticsOptions): Promise<import("axios").AxiosResponse<any, any, {}>>;
25
+ export declare function runAnalytics(pipe: string, options: AnalyticsOptions): Promise<unknown>;
@@ -5,6 +5,8 @@ exports.buildAnalyticsParams = buildAnalyticsParams;
5
5
  exports.runAnalytics = runAnalytics;
6
6
  const incur_1 = require("incur");
7
7
  const client_1 = require("../lib/client");
8
+ const filters_1 = require("../lib/filters");
9
+ const json_1 = require("../lib/json");
8
10
  exports.analytics = incur_1.Cli.create('analytics', {
9
11
  description: 'Pre-built analytics query commands — KPIs, funnels, retention, revenue, and top-N breakdowns',
10
12
  });
@@ -43,6 +45,52 @@ const RESERVED_PARAM_KEYS = new Set([
43
45
  'dateTo',
44
46
  'filters',
45
47
  ]);
48
+ const ANALYTICS_FILTER_KEYS = new Set(['field', 'op', 'value', 'filters']);
49
+ const ANALYTICS_NESTED_FILTER_KEYS = new Set(['field', 'op', 'value']);
50
+ function validateAnalyticsFilter(filter, path, allowNested) {
51
+ if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
52
+ throw new Error(`${path} must be a {field, op, value} object`);
53
+ }
54
+ const record = filter;
55
+ if (!allowNested && record.filters !== undefined) {
56
+ throw new Error(`${path}.filters must be a one-level array of leaf filters`);
57
+ }
58
+ const allowedKeys = allowNested
59
+ ? ANALYTICS_FILTER_KEYS
60
+ : ANALYTICS_NESTED_FILTER_KEYS;
61
+ if (Object.keys(record).some((key) => !allowedKeys.has(key))) {
62
+ throw new Error(`${path} may only contain field, op, value${allowNested ? ', and filters' : ''}`);
63
+ }
64
+ if (typeof record.field !== 'string' || record.field.length === 0) {
65
+ throw new Error(`${path} requires a non-empty string "field"`);
66
+ }
67
+ if (!(0, filters_1.isCanonicalFilterOperator)(record.op)) {
68
+ throw new Error(`${path} requires a canonical "op"`);
69
+ }
70
+ if ((0, filters_1.isEmptyMembershipArray)(record.value)) {
71
+ throw new Error(`${path}: membership arrays cannot be empty`);
72
+ }
73
+ if (!(0, filters_1.isValuelessFilterOperator)(record.op) &&
74
+ (record.value === undefined ||
75
+ record.value === null ||
76
+ !(0, filters_1.isCanonicalFilterValue)(record.value))) {
77
+ throw new Error(`${path}: "value" is required for every operator except notEmpty/isEmpty`);
78
+ }
79
+ if (record.value !== undefined &&
80
+ record.value !== null &&
81
+ !(0, filters_1.isCanonicalFilterValue)(record.value)) {
82
+ throw new Error(`${path}: "value" must be a string, number, boolean, or string/number array`);
83
+ }
84
+ if ((0, filters_1.hasTinybirdMembershipDelimiter)(record.value)) {
85
+ throw new Error(`${path}: array string members cannot contain "|" because it is the Tinybird membership separator`);
86
+ }
87
+ if (record.filters !== undefined) {
88
+ if (!allowNested || !Array.isArray(record.filters)) {
89
+ throw new Error(`${path}.filters must be a one-level array of leaf filters`);
90
+ }
91
+ record.filters.forEach((nested, index) => validateAnalyticsFilter(nested, `${path}.filters[${index}]`, false));
92
+ }
93
+ }
46
94
  /**
47
95
  * Build the query-string params for an analytics pipe request.
48
96
  *
@@ -62,16 +110,7 @@ function buildAnalyticsParams(options) {
62
110
  const out = {};
63
111
  // --params first, so the validated flags below override it.
64
112
  if (options.params) {
65
- let parsed;
66
- try {
67
- parsed = JSON.parse(options.params);
68
- }
69
- catch {
70
- throw new Error('--params must be a valid JSON object');
71
- }
72
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
73
- throw new Error('--params must be a valid JSON object');
74
- }
113
+ const parsed = (0, json_1.parseJsonObject)(options.params, '--params');
75
114
  for (const [key, value] of Object.entries(parsed)) {
76
115
  if (RESERVED_PARAM_KEYS.has(key)) {
77
116
  throw new Error(`--params may not set "${key}" — use the --date-from/--date-to/--filters flags instead`);
@@ -101,6 +140,7 @@ function buildAnalyticsParams(options) {
101
140
  if (!Array.isArray(parsed)) {
102
141
  throw new Error('--filters must be a valid JSON array of {field,op,value} objects');
103
142
  }
143
+ parsed.forEach((filter, index) => validateAnalyticsFilter(filter, `--filters[${index}]`, true));
104
144
  out.filters = JSON.stringify(parsed);
105
145
  }
106
146
  return out;
@@ -123,7 +163,7 @@ const sharedOptions = incur_1.z.object({
123
163
  .string()
124
164
  .optional()
125
165
  .describe('JSON array of filter conditions: [{"field","op","value"}]. ' +
126
- 'Use op "in"/"nin" with a pipe-delimited value (e.g. "chrome|firefox").'),
166
+ 'Use op "in"/"nin" with an array value (e.g. ["chrome","firefox"]); pipe-delimited strings are also accepted. Array string members cannot contain "|".'),
127
167
  params: incur_1.z
128
168
  .string()
129
169
  .optional()
@@ -1,11 +1,9 @@
1
1
  import { Cli } from 'incur';
2
+ import { type PaginationOptions } from '../lib/pagination';
3
+ export type { PaginationOptions };
2
4
  export declare const boards: Cli.Cli<{}, undefined, undefined>;
3
- export interface PaginationOptions {
4
- page?: number;
5
- size?: number;
6
- }
7
- export declare function listBoardsRun(options?: PaginationOptions): Promise<import("axios").AxiosResponse<any, any, {}>>;
8
- export declare function getBoardRun(boardId: string): Promise<import("axios").AxiosResponse<any, any, {}>>;
5
+ export declare function listBoardsRun(options?: PaginationOptions): Promise<unknown>;
6
+ export declare function getBoardRun(boardId: string): Promise<unknown>;
9
7
  export interface CreateBoardOptions {
10
8
  title?: string;
11
9
  name?: string;
@@ -13,12 +11,12 @@ export interface CreateBoardOptions {
13
11
  isPublic?: boolean;
14
12
  }
15
13
  export declare function buildBoardBody(options: CreateBoardOptions | UpdateBoardOptions): Record<string, unknown>;
16
- export declare function createBoardRun(options: CreateBoardOptions): Promise<import("axios").AxiosResponse<any, any, {}>>;
14
+ export declare function createBoardRun(options: CreateBoardOptions): Promise<unknown>;
17
15
  export interface UpdateBoardOptions {
18
16
  title?: string;
19
17
  name?: string;
20
18
  description?: string;
21
19
  isPublic?: boolean;
22
20
  }
23
- export declare function updateBoardRun(boardId: string, options: UpdateBoardOptions): Promise<import("axios").AxiosResponse<any, any, {}>>;
24
- export declare function deleteBoardRun(boardId: string): Promise<import("axios").AxiosResponse<any, any, {}>>;
21
+ export declare function updateBoardRun(boardId: string, options: UpdateBoardOptions): Promise<unknown>;
22
+ export declare function deleteBoardRun(boardId: string): Promise<unknown>;
@@ -9,29 +9,19 @@ exports.updateBoardRun = updateBoardRun;
9
9
  exports.deleteBoardRun = deleteBoardRun;
10
10
  const incur_1 = require("incur");
11
11
  const client_1 = require("../lib/client");
12
+ const pagination_1 = require("../lib/pagination");
12
13
  exports.boards = incur_1.Cli.create('boards', {
13
14
  description: 'Dashboard board commands — create, list, update, and delete boards',
14
15
  });
15
- function buildPaginationParams(options = {}) {
16
- const params = {};
17
- if (options.page !== undefined)
18
- params.page = options.page;
19
- if (options.size !== undefined)
20
- params.size = options.size;
21
- return params;
22
- }
23
16
  // ── List boards ──
24
17
  function listBoardsRun(options = {}) {
25
18
  (0, client_1.requireApiKey)();
26
19
  const client = (0, client_1.createClient)();
27
- return client.get('/v0/boards/', { params: buildPaginationParams(options) });
20
+ return client.get('/v0/boards/', { params: (0, pagination_1.buildPaginationParams)(options) });
28
21
  }
29
22
  exports.boards.command('list', {
30
23
  description: 'List all boards for the project',
31
- options: incur_1.z.object({
32
- page: incur_1.z.coerce.number().optional().describe('Page number (1-indexed, default 1)'),
33
- size: incur_1.z.coerce.number().optional().describe('Page size (default 100, max 200)'),
34
- }),
24
+ options: incur_1.z.object(pagination_1.paginationOptionsSchema),
35
25
  examples: [{ description: 'List all dashboard boards' }],
36
26
  hint: 'Requires boards:read scope on your API key.',
37
27
  run({ options }) {
@@ -62,7 +52,7 @@ function buildBoardBody(options) {
62
52
  const body = {};
63
53
  if (title !== undefined) {
64
54
  if (!title)
65
- throw new Error('--title must not be empty');
55
+ throw new Error('--title (or its deprecated alias --name) must not be empty');
66
56
  body.title = title;
67
57
  }
68
58
  if (options.description !== undefined) {