@formo/cli 0.2.0 → 1.0.2

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.
@@ -1,25 +1,89 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.profilesLabels = exports.profiles = void 0;
3
+ exports.profilesLabels = exports.profilesProperties = exports.profiles = void 0;
4
4
  exports.getProfileRun = getProfileRun;
5
+ exports.parseSearchConditions = parseSearchConditions;
5
6
  exports.searchProfilesRun = searchProfilesRun;
6
7
  exports.buildUpdateProfileBody = buildUpdateProfileBody;
7
8
  exports.updateProfileRun = updateProfileRun;
9
+ exports.buildBatchUpdateProfilesBody = buildBatchUpdateProfilesBody;
10
+ exports.batchUpdateProfilesRun = batchUpdateProfilesRun;
8
11
  exports.buildCreateLabelBody = buildCreateLabelBody;
9
12
  exports.createProfileLabelRun = createProfileLabelRun;
13
+ exports.buildBatchCreateLabelsBody = buildBatchCreateLabelsBody;
14
+ exports.batchCreateProfileLabelsRun = batchCreateProfileLabelsRun;
10
15
  exports.buildDeleteLabelBody = buildDeleteLabelBody;
11
16
  exports.deleteProfileLabelRun = deleteProfileLabelRun;
12
17
  const incur_1 = require("incur");
13
18
  const client_1 = require("../lib/client");
19
+ const json_1 = require("../lib/json");
14
20
  exports.profiles = incur_1.Cli.create('profiles', {
15
21
  description: 'Wallet profile commands',
16
22
  });
17
- function getProfileRun(address, expand) {
23
+ function addLifecycleThresholdParams(params, options) {
24
+ if (options.newWindowDays !== undefined) {
25
+ params.new_window_days = options.newWindowDays;
26
+ }
27
+ if (options.churnWindowDays !== undefined) {
28
+ params.churn_window_days = options.churnWindowDays;
29
+ }
30
+ if (options.powerUserMinActiveDays !== undefined) {
31
+ params.power_user_min_active_days = options.powerUserMinActiveDays;
32
+ }
33
+ if (options.powerUserWindowDays !== undefined) {
34
+ params.power_user_window_days = options.powerUserWindowDays;
35
+ }
36
+ if (options.resurrectedGapDays !== undefined) {
37
+ params.resurrected_gap_days = options.resurrectedGapDays;
38
+ }
39
+ if (options.atRiskMinDaysInactive !== undefined) {
40
+ params.at_risk_min_days_inactive = options.atRiskMinDaysInactive;
41
+ }
42
+ if (options.atRiskPriorActiveDaysThreshold !== undefined) {
43
+ params.at_risk_prior_active_days_threshold =
44
+ options.atRiskPriorActiveDaysThreshold;
45
+ }
46
+ }
47
+ const lifecycleThresholdOptions = {
48
+ newWindowDays: incur_1.z.coerce
49
+ .number()
50
+ .optional()
51
+ .describe('Override lifecycle new-user window in days'),
52
+ churnWindowDays: incur_1.z.coerce
53
+ .number()
54
+ .optional()
55
+ .describe('Override lifecycle churn window in days'),
56
+ powerUserMinActiveDays: incur_1.z.coerce
57
+ .number()
58
+ .optional()
59
+ .describe('Override lifecycle power-user minimum active days'),
60
+ powerUserWindowDays: incur_1.z.coerce
61
+ .number()
62
+ .optional()
63
+ .describe('Override lifecycle power-user window in days'),
64
+ resurrectedGapDays: incur_1.z.coerce
65
+ .number()
66
+ .optional()
67
+ .describe('Override lifecycle resurrected gap in days'),
68
+ atRiskMinDaysInactive: incur_1.z.coerce
69
+ .number()
70
+ .optional()
71
+ .describe('Override lifecycle at-risk minimum inactive days'),
72
+ atRiskPriorActiveDaysThreshold: incur_1.z.coerce
73
+ .number()
74
+ .optional()
75
+ .describe('Override lifecycle at-risk prior active days threshold'),
76
+ };
77
+ function getProfileRun(address, optionsOrExpand = {}) {
18
78
  (0, client_1.requireApiKey)();
19
79
  const client = (0, client_1.createClient)();
80
+ const options = typeof optionsOrExpand === 'string'
81
+ ? { expand: optionsOrExpand }
82
+ : optionsOrExpand;
20
83
  const params = {};
21
- if (expand)
22
- params.expand = expand;
84
+ if (options.expand)
85
+ params.expand = options.expand;
86
+ addLifecycleThresholdParams(params, options);
23
87
  return client.get(`/v0/profiles/${encodeURIComponent(address)}`, { params });
24
88
  }
25
89
  exports.profiles.command('get', {
@@ -32,6 +96,7 @@ exports.profiles.command('get', {
32
96
  .string()
33
97
  .optional()
34
98
  .describe('Comma-separated list of fields to expand: apps,chains,tokens,labels'),
99
+ ...lifecycleThresholdOptions,
35
100
  }),
36
101
  examples: [
37
102
  { args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, description: 'Get a wallet profile' },
@@ -41,16 +106,68 @@ exports.profiles.command('get', {
41
106
  description: 'Get profile with expanded labels and chains',
42
107
  },
43
108
  ],
109
+ hint: 'Requires profiles:read scope on your API key.',
44
110
  run({ args, options }) {
45
- return getProfileRun(args.address, options.expand);
111
+ return getProfileRun(args.address, options);
46
112
  },
47
113
  });
114
+ // Accepted first segments for a FilterCondition `field`, mirroring the API's
115
+ // parseField(). A field whose prefix is not one of these is silently ignored
116
+ // server-side (no error, no filtering — the search returns everything), so we
117
+ // reject it client-side with an actionable message instead.
118
+ const CONDITION_FIELD_PREFIXES = new Set([
119
+ 'user',
120
+ 'users',
121
+ 'chain',
122
+ 'chains',
123
+ 'app',
124
+ 'apps',
125
+ 'token',
126
+ 'tokens',
127
+ 'label',
128
+ 'labels',
129
+ ]);
130
+ /**
131
+ * Parse and validate the --conditions JSON. Ensures it is an array of
132
+ * `{ field, op, value }` objects whose `field` is a typed path (e.g.
133
+ * `users.net_worth_usd`) — a bare name like `net_worth_usd` is silently
134
+ * dropped by the API, so it is rejected here. Exported for unit testing.
135
+ */
136
+ function parseSearchConditions(raw) {
137
+ let parsed;
138
+ try {
139
+ parsed = JSON.parse(raw);
140
+ }
141
+ catch {
142
+ throw new Error('--conditions must be a valid JSON array of FilterCondition objects');
143
+ }
144
+ if (!Array.isArray(parsed)) {
145
+ throw new Error('--conditions must be a valid JSON array of FilterCondition objects');
146
+ }
147
+ for (const cond of parsed) {
148
+ if (!cond || typeof cond !== 'object' || Array.isArray(cond)) {
149
+ throw new Error('--conditions: each entry must be an object with field, op, value');
150
+ }
151
+ const field = cond.field;
152
+ if (typeof field !== 'string' || field.length === 0) {
153
+ throw new Error('--conditions: each entry must have a non-empty string "field"');
154
+ }
155
+ if (!field.includes('.') || !CONDITION_FIELD_PREFIXES.has(field.split('.')[0])) {
156
+ throw new Error(`--conditions: field "${field}" must be a typed path — prefix it with ` +
157
+ 'users., chains., apps., tokens., or labels. ' +
158
+ '(a bare name is silently ignored by the API and returns the entire unfiltered dataset)');
159
+ }
160
+ }
161
+ return parsed;
162
+ }
48
163
  function searchProfilesRun(options) {
49
164
  (0, client_1.requireApiKey)();
50
165
  const client = (0, client_1.createClient)();
51
166
  const params = {};
52
167
  if (options.address)
53
168
  params.address = options.address;
169
+ if (options.search)
170
+ params.search = options.search;
54
171
  if (options.page !== undefined)
55
172
  params.page = options.page;
56
173
  if (options.size !== undefined)
@@ -61,24 +178,27 @@ function searchProfilesRun(options) {
61
178
  params.order_dir = options.orderDir;
62
179
  if (options.expand)
63
180
  params.expand = options.expand;
181
+ addLifecycleThresholdParams(params, options);
64
182
  let body;
65
183
  if (options.conditions) {
66
- try {
67
- const conditions = JSON.parse(options.conditions);
68
- if (!Array.isArray(conditions))
69
- throw new Error('not an array');
70
- body = { conditions, logic: options.logic ?? 'and' };
71
- }
72
- catch {
73
- throw new Error('--conditions must be valid JSON array of FilterCondition objects');
74
- }
184
+ body = {
185
+ conditions: parseSearchConditions(options.conditions),
186
+ logic: options.logic ?? 'and',
187
+ };
75
188
  }
189
+ // INTENTIONAL: the Formo search API is `GET /v0/profiles` with the
190
+ // `{ conditions, logic }` filter object in the *request body* (see
191
+ // docs.formo.so/api/profiles/search — it has a "Request Body (Filters)"
192
+ // section under a GET endpoint). This GET-with-body shape is the
193
+ // documented, server-supported contract. Do NOT "fix" it to POST — that
194
+ // breaks the API. Filter-less searches still go over query params only.
76
195
  return client.request({ method: 'get', url: '/v0/profiles/', params, data: body });
77
196
  }
78
197
  exports.profiles.command('search', {
79
198
  description: 'Search wallet profiles with optional filters',
80
199
  options: incur_1.z.object({
81
200
  address: incur_1.z.string().optional().describe('Filter by wallet address'),
201
+ search: incur_1.z.string().optional().describe('Free-text search across address and identity fields'),
82
202
  page: incur_1.z.coerce.number().optional().describe('Page number (1-indexed, default 1)'),
83
203
  size: incur_1.z.coerce.number().optional().describe('Page size (default 100, max 1000)'),
84
204
  orderBy: incur_1.z
@@ -102,11 +222,20 @@ exports.profiles.command('search', {
102
222
  conditions: incur_1.z
103
223
  .string()
104
224
  .optional()
105
- .describe('JSON array of FilterCondition objects for advanced filtering'),
225
+ .describe('JSON array of FilterCondition objects: [{"field","op","value"}]. ' +
226
+ 'The "field" MUST be a typed path — a bare name like "net_worth_usd" is silently ignored. ' +
227
+ 'Profile: users.net_worth_usd, users.volume, users.revenue, users.points. ' +
228
+ 'Engagement: users.device, users.browser, users.os, users.location, users.lifecycle. ' +
229
+ 'Socials: users.ens, users.farcaster, users.lens, etc. ' +
230
+ 'Chains: chains.balance or chains.{chain_id}.balance. ' +
231
+ 'Apps: apps.{app_id}.balance. Tokens: tokens.{address}.balance ' +
232
+ '(optional "scope":"any"|"protocol" + "appId"). Labels: labels.{tag_id}. ' +
233
+ 'op: eq, neq, gt, gte, lt, lte, in, nin.'),
106
234
  logic: incur_1.z
107
235
  .enum(['and', 'or'])
108
236
  .optional()
109
237
  .describe('Logic operator for combining conditions: "and" (default) or "or"'),
238
+ ...lifecycleThresholdOptions,
110
239
  }),
111
240
  examples: [
112
241
  { options: { size: 10 }, description: 'List first 10 profiles' },
@@ -120,34 +249,34 @@ exports.profiles.command('search', {
120
249
  },
121
250
  {
122
251
  options: {
123
- conditions: '[{"field":"net_worth_usd","op":"gt","value":10000}]',
252
+ conditions: '[{"field":"users.net_worth_usd","op":"gt","value":10000}]',
124
253
  size: 20,
125
254
  },
126
- description: 'Search profiles with net worth > 10000',
255
+ description: 'Search profiles with net worth > $10k',
127
256
  },
128
257
  {
129
258
  options: {
130
- conditions: '[{"field":"net_worth_usd","op":"gt","value":10000},{"field":"tx_count","op":"gt","value":50}]',
259
+ conditions: '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]',
131
260
  logic: 'or',
132
261
  size: 20,
133
262
  },
134
- description: 'Search profiles matching either condition',
263
+ description: 'Search profiles matching either condition (net worth or volume)',
264
+ },
265
+ {
266
+ options: {
267
+ conditions: '[{"field":"chains.1.balance","op":"gt","value":1000}]',
268
+ size: 20,
269
+ },
270
+ description: 'Search profiles with > $1k balance on Ethereum (chain 1)',
135
271
  },
136
272
  ],
273
+ hint: 'Requires profiles:read scope on your API key. Filter "field" must be a typed path (e.g. users.net_worth_usd) — bare names are ignored by the API.',
137
274
  run({ args: _args, options }) {
138
275
  return searchProfilesRun(options);
139
276
  },
140
277
  });
141
278
  function buildUpdateProfileBody(options) {
142
- let body;
143
- try {
144
- body = JSON.parse(options.properties);
145
- if (!body || typeof body !== 'object' || Array.isArray(body))
146
- throw new Error('not an object');
147
- }
148
- catch {
149
- throw new Error('--properties must be a JSON object of property keys');
150
- }
279
+ const body = (0, json_1.parseJsonObject)(options.properties, '--properties');
151
280
  if (Object.keys(body).length === 0) {
152
281
  throw new Error('--properties must contain at least one key');
153
282
  }
@@ -187,6 +316,46 @@ exports.profiles.command('update', {
187
316
  return updateProfileRun(args.address, options);
188
317
  },
189
318
  });
319
+ function buildBatchUpdateProfilesBody(options) {
320
+ const rows = (0, json_1.parseJsonArrayOfObjects)(options.rows, '--rows');
321
+ if (rows.length === 0) {
322
+ throw new Error('--rows must contain at least one item');
323
+ }
324
+ for (const row of rows) {
325
+ if (typeof row.address !== 'string' || row.address.length === 0) {
326
+ throw new Error('--rows entries must each include a non-empty string address');
327
+ }
328
+ }
329
+ return rows;
330
+ }
331
+ function batchUpdateProfilesRun(options) {
332
+ (0, client_1.requireApiKey)();
333
+ const client = (0, client_1.createClient)();
334
+ return client.post('/v0/profiles/properties', buildBatchUpdateProfilesBody(options));
335
+ }
336
+ exports.profilesProperties = incur_1.Cli.create('properties', {
337
+ description: 'Manage first-party profile properties in bulk',
338
+ });
339
+ exports.profilesProperties.command('batch', {
340
+ description: 'Batch update first-party profile properties for up to 100 wallets',
341
+ options: incur_1.z.object({
342
+ rows: incur_1.z
343
+ .string()
344
+ .describe('JSON array of flat {address,...properties} objects. ENS names are not resolved in batch requests.'),
345
+ }),
346
+ examples: [
347
+ {
348
+ options: {
349
+ rows: '[{"address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","display_name":"alice.eth","email":"alice@example.com"}]',
350
+ },
351
+ description: 'Batch set display names and emails',
352
+ },
353
+ ],
354
+ hint: 'Requires profiles:write scope on your API key. Unknown keys are ignored by the API; invalid rows are quarantined.',
355
+ run({ options }) {
356
+ return batchUpdateProfilesRun(options);
357
+ },
358
+ });
190
359
  // ── Labels sub-resource ──
191
360
  exports.profilesLabels = incur_1.Cli.create('labels', {
192
361
  description: 'Manage labels on a wallet profile',
@@ -205,13 +374,18 @@ function buildCreateLabelBody(options) {
205
374
  }
206
375
  if (options.tagId) {
207
376
  const single = { tag_id: options.tagId };
208
- if (options.value)
377
+ if (options.value !== undefined)
209
378
  single.value = options.value;
210
379
  if (options.chainId)
211
380
  single.chain_id = options.chainId;
381
+ if (options.timestamp)
382
+ single.timestamp = options.timestamp;
383
+ if (options.isDeleted !== undefined) {
384
+ single._is_deleted = options.isDeleted ? 1 : 0;
385
+ }
212
386
  return single;
213
387
  }
214
- throw new Error('Provide --tagId (single label) or --labels (batch JSON array)');
388
+ throw new Error('Provide --tag-id (single label) or --labels (batch JSON array)');
215
389
  }
216
390
  function createProfileLabelRun(address, options) {
217
391
  (0, client_1.requireApiKey)();
@@ -230,10 +404,18 @@ exports.profilesLabels.command('create', {
230
404
  .describe('Label identifier (e.g. "vip", "airdrop_eligible")'),
231
405
  value: incur_1.z.string().optional().describe('Optional label value (e.g. tier name, country code)'),
232
406
  chainId: incur_1.z.string().optional().describe('Optional chain identifier the label applies to'),
407
+ timestamp: incur_1.z
408
+ .string()
409
+ .optional()
410
+ .describe('Optional historical ISO-8601 timestamp for the label row'),
411
+ isDeleted: incur_1.z
412
+ .boolean()
413
+ .optional()
414
+ .describe('Set true with --timestamp to backfill a label removal tombstone'),
233
415
  labels: incur_1.z
234
416
  .string()
235
417
  .optional()
236
- .describe('JSON array of UserLabelInput objects for batch upsert'),
418
+ .describe('JSON array of UserLabelInput objects for this wallet'),
237
419
  }),
238
420
  examples: [
239
421
  {
@@ -246,6 +428,11 @@ exports.profilesLabels.command('create', {
246
428
  options: { tagId: 'tier', value: 'gold', chainId: '1' },
247
429
  description: 'Apply a tiered label scoped to a chain',
248
430
  },
431
+ {
432
+ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
433
+ options: { tagId: 'tier', timestamp: '2024-03-15T00:00:00.000Z', isDeleted: true },
434
+ description: 'Backfill a historical label removal',
435
+ },
249
436
  {
250
437
  args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
251
438
  options: { labels: '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]' },
@@ -257,9 +444,49 @@ exports.profilesLabels.command('create', {
257
444
  return createProfileLabelRun(args.address, options);
258
445
  },
259
446
  });
447
+ function buildBatchCreateLabelsBody(options) {
448
+ const labels = (0, json_1.parseJsonArrayOfObjects)(options.labels, '--labels');
449
+ if (labels.length === 0) {
450
+ throw new Error('--labels must contain at least one item');
451
+ }
452
+ for (const label of labels) {
453
+ if (typeof label.address !== 'string' || label.address.length === 0) {
454
+ throw new Error('--labels entries must each include a non-empty string address');
455
+ }
456
+ if (typeof label.tag_id !== 'string' || label.tag_id.length === 0) {
457
+ throw new Error('--labels entries must each include a non-empty string tag_id');
458
+ }
459
+ }
460
+ return labels;
461
+ }
462
+ function batchCreateProfileLabelsRun(options) {
463
+ (0, client_1.requireApiKey)();
464
+ const client = (0, client_1.createClient)();
465
+ return client.post('/v0/profiles/labels', buildBatchCreateLabelsBody(options));
466
+ }
467
+ exports.profilesLabels.command('batch', {
468
+ description: 'Batch upsert labels across up to 100 wallets',
469
+ options: incur_1.z.object({
470
+ labels: incur_1.z
471
+ .string()
472
+ .describe('JSON array of {address,tag_id,value?,chain_id?,timestamp?,_is_deleted?} objects'),
473
+ }),
474
+ examples: [
475
+ {
476
+ options: {
477
+ labels: '[{"address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","tag_id":"vip","value":"tier-1"}]',
478
+ },
479
+ description: 'Batch upsert labels for multiple wallets',
480
+ },
481
+ ],
482
+ hint: 'Requires profiles:write scope on your API key. ENS names are not resolved in batch requests.',
483
+ run({ options }) {
484
+ return batchCreateProfileLabelsRun(options);
485
+ },
486
+ });
260
487
  function buildDeleteLabelBody(options) {
261
488
  if (!options.tagId) {
262
- throw new Error('--tagId is required');
489
+ throw new Error('--tag-id is required');
263
490
  }
264
491
  const body = { tag_id: options.tagId };
265
492
  if (options.chainId)
@@ -297,4 +524,5 @@ exports.profilesLabels.command('delete', {
297
524
  return deleteProfileLabelRun(args.address, options);
298
525
  },
299
526
  });
527
+ exports.profiles.command(exports.profilesProperties);
300
528
  exports.profiles.command(exports.profilesLabels);
@@ -4,13 +4,17 @@ exports.query = void 0;
4
4
  exports.queryRunRun = queryRunRun;
5
5
  const incur_1 = require("incur");
6
6
  const client_1 = require("../lib/client");
7
+ const sql_1 = require("../lib/sql");
7
8
  exports.query = incur_1.Cli.create('query', {
8
9
  description: 'SQL analytics query commands',
9
10
  });
10
11
  function queryRunRun(sql) {
11
12
  (0, client_1.requireApiKey)();
12
13
  const client = (0, client_1.createClient)();
13
- return client.post('/v0/query/', { query: sql });
14
+ // The API wraps the query in a paginating subquery with its own
15
+ // `FORMAT JSON`. ClickHouse forbids a `FORMAT` clause inside a subquery, so
16
+ // strip any trailing `FORMAT`/semicolon before sending to avoid a 400.
17
+ return client.post('/v0/query/', { query: (0, sql_1.stripTrailingFormatClause)(sql) });
14
18
  }
15
19
  exports.query.command('run', {
16
20
  description: 'Run a SQL query against your Formo analytics data',
@@ -1,13 +1,17 @@
1
1
  import { Cli } from 'incur';
2
2
  export declare const segments: Cli.Cli<{}, undefined, undefined>;
3
- export declare function listSegmentsRun(): Promise<import("axios").AxiosResponse<any, any, {}>>;
3
+ export interface PaginationOptions {
4
+ page?: number;
5
+ size?: number;
6
+ }
7
+ export declare function listSegmentsRun(options?: PaginationOptions): Promise<import("axios").AxiosResponse<any, any, {}>>;
4
8
  export interface CreateSegmentOptions {
5
9
  title: string;
6
10
  filterSets: string;
7
11
  }
8
12
  export declare function buildCreateSegmentBody(options: CreateSegmentOptions): {
9
13
  title: string;
10
- filterSets: unknown;
14
+ filterSets: any[];
11
15
  };
12
16
  export declare function createSegmentRun(options: CreateSegmentOptions): Promise<import("axios").AxiosResponse<any, any, {}>>;
13
17
  export declare function deleteSegmentRun(segmentId: string): Promise<import("axios").AxiosResponse<any, any, {}>>;
@@ -10,27 +10,41 @@ const client_1 = require("../lib/client");
10
10
  exports.segments = incur_1.Cli.create('segments', {
11
11
  description: 'User segment commands — create, list, and delete audience segments',
12
12
  });
13
- // ── List segments ──
14
- function listSegmentsRun() {
13
+ function buildPaginationParams(options = {}) {
14
+ const params = {};
15
+ if (options.page !== undefined)
16
+ params.page = options.page;
17
+ if (options.size !== undefined)
18
+ params.size = options.size;
19
+ return params;
20
+ }
21
+ function listSegmentsRun(options = {}) {
15
22
  (0, client_1.requireApiKey)();
16
23
  const client = (0, client_1.createClient)();
17
- return client.get('/v0/segments/');
24
+ return client.get('/v0/segments/', { params: buildPaginationParams(options) });
18
25
  }
19
26
  exports.segments.command('list', {
20
27
  description: 'List all user segments for the project',
28
+ options: incur_1.z.object({
29
+ page: incur_1.z.coerce.number().optional().describe('Page number (1-indexed, default 1)'),
30
+ size: incur_1.z.coerce.number().optional().describe('Page size (default 100, max 200)'),
31
+ }),
21
32
  examples: [{ description: 'List all project segments' }],
22
33
  hint: 'Requires segments:read scope on your API key.',
23
- run() {
24
- return listSegmentsRun();
34
+ run({ options }) {
35
+ return listSegmentsRun(options);
25
36
  },
26
37
  });
27
38
  function buildCreateSegmentBody(options) {
28
39
  let parsedFilterSets;
29
40
  try {
30
41
  parsedFilterSets = JSON.parse(options.filterSets);
42
+ if (!Array.isArray(parsedFilterSets)) {
43
+ throw new Error('not an array');
44
+ }
31
45
  }
32
46
  catch {
33
- throw new Error('--filterSets must be a valid JSON array');
47
+ throw new Error('--filter-sets must be a valid JSON array');
34
48
  }
35
49
  return {
36
50
  title: options.title,
package/dist/index.js CHANGED
@@ -3,18 +3,20 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const incur_1 = require("incur");
5
5
  const alerts_1 = require("./commands/alerts");
6
+ const analytics_1 = require("./commands/analytics");
6
7
  const boards_1 = require("./commands/boards");
7
8
  const charts_1 = require("./commands/charts");
8
9
  const contracts_1 = require("./commands/contracts");
10
+ const events_1 = require("./commands/events");
9
11
  const import_1 = require("./commands/import");
10
12
  const profiles_1 = require("./commands/profiles");
11
13
  const query_1 = require("./commands/query");
12
14
  const segments_1 = require("./commands/segments");
15
+ const client_1 = require("./lib/client");
13
16
  const config_1 = require("./lib/config");
14
17
  const ui_1 = require("./lib/ui");
15
18
  const DASHBOARD_URL = "https://app.formo.so";
16
19
  const DOCS_URL = "https://docs.formo.so";
17
- const API_BASE_URL = "https://api.formo.so";
18
20
  function loginGuide() {
19
21
  return [
20
22
  "",
@@ -37,7 +39,7 @@ function loginGuide() {
37
39
  }
38
40
  async function validateAndFetchWorkspace(apiKey) {
39
41
  try {
40
- const res = await fetch(`${API_BASE_URL}/api/validate-api-key`, {
42
+ const res = await fetch(`${(0, client_1.getApiBaseUrl)()}/api/validate-api-key`, {
41
43
  method: "POST",
42
44
  headers: { "Content-Type": "application/json" },
43
45
  body: JSON.stringify({ apiKey }),
@@ -57,21 +59,29 @@ async function validateAndFetchWorkspace(apiKey) {
57
59
  }
58
60
  }
59
61
  const cli = incur_1.Cli.create("formo", {
60
- version: "0.2.0",
62
+ version: "1.0.1",
61
63
  description: "Formo API CLI — Web3 analytics from the terminal",
62
64
  sync: {
63
65
  suggestions: [
64
66
  "get the profile for wallet 0xabc",
65
67
  "search profiles with net worth > 10000",
66
68
  "run a SQL query on my analytics data",
69
+ "show traffic KPIs for the last 7 days",
70
+ "get the conversion funnel for the last month",
71
+ "list the top wallets by activity",
67
72
  "search profiles ordered by last_onchain desc",
68
73
  "list all project alerts",
69
74
  "create an alert for high-value transactions",
70
75
  "list charts in a board",
76
+ "create a line chart in a board",
77
+ "move or duplicate a dashboard chart",
71
78
  "list all tracked contracts",
72
79
  "register a new smart contract",
73
80
  "list user segments",
74
81
  "import wallet addresses",
82
+ "batch update profile properties with profiles properties batch",
83
+ "batch upsert labels for wallets",
84
+ "send raw analytics events",
75
85
  ],
76
86
  },
77
87
  });
@@ -234,10 +244,12 @@ cli.command("status", {
234
244
  // ── command groups ──
235
245
  cli.command(profiles_1.profiles);
236
246
  cli.command(query_1.query);
247
+ cli.command(analytics_1.analytics);
237
248
  cli.command(alerts_1.alerts);
238
249
  cli.command(boards_1.boards);
239
250
  cli.command(charts_1.charts);
240
251
  cli.command(contracts_1.contracts);
252
+ cli.command(events_1.events);
241
253
  cli.command(segments_1.segments);
242
254
  cli.command(import_1.importCmd);
243
255
  // Show banner when run with no args (root help)
@@ -1,4 +1,8 @@
1
1
  import { AxiosError } from 'axios';
2
+ export declare const DEFAULT_API_BASE_URL = "https://api.formo.so";
3
+ export declare const DEFAULT_EVENTS_BASE_URL = "https://events.formo.so";
4
+ export declare function getApiBaseUrl(): string;
5
+ export declare function getEventsBaseUrl(): string;
2
6
  export interface ApiErrorBody {
3
7
  error?: {
4
8
  code?: string;
@@ -24,6 +28,11 @@ export interface DecoratedApiError extends Error {
24
28
  * Exported for unit testing — used by the response interceptor below.
25
29
  */
26
30
  export declare function parseApiError(error: AxiosError): DecoratedApiError;
27
- declare function createClient(): import("axios").AxiosInstance;
31
+ export interface ClientOptions {
32
+ baseURL?: string;
33
+ apiKey?: string;
34
+ }
35
+ declare function createClient(options?: ClientOptions): import("axios").AxiosInstance;
36
+ export declare function createEventsClient(writeKey: string): import("axios").AxiosInstance;
28
37
  export declare function requireApiKey(): void;
29
38
  export { createClient };