@formo/cli 0.1.0 → 1.0.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.
@@ -1,8 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.profiles = void 0;
3
+ exports.profilesLabels = exports.profiles = void 0;
4
4
  exports.getProfileRun = getProfileRun;
5
+ exports.parseSearchConditions = parseSearchConditions;
5
6
  exports.searchProfilesRun = searchProfilesRun;
7
+ exports.buildUpdateProfileBody = buildUpdateProfileBody;
8
+ exports.updateProfileRun = updateProfileRun;
9
+ exports.buildCreateLabelBody = buildCreateLabelBody;
10
+ exports.createProfileLabelRun = createProfileLabelRun;
11
+ exports.buildDeleteLabelBody = buildDeleteLabelBody;
12
+ exports.deleteProfileLabelRun = deleteProfileLabelRun;
6
13
  const incur_1 = require("incur");
7
14
  const client_1 = require("../lib/client");
8
15
  exports.profiles = incur_1.Cli.create('profiles', {
@@ -35,20 +42,70 @@ exports.profiles.command('get', {
35
42
  description: 'Get profile with expanded labels and chains',
36
43
  },
37
44
  ],
45
+ hint: 'Requires profiles:read scope on your API key.',
38
46
  run({ args, options }) {
39
47
  return getProfileRun(args.address, options.expand);
40
48
  },
41
49
  });
50
+ // Accepted first segments for a FilterCondition `field`, mirroring the API's
51
+ // parseField(). A field whose prefix is not one of these is silently ignored
52
+ // server-side (no error, no filtering — the search returns everything), so we
53
+ // reject it client-side with an actionable message instead.
54
+ const CONDITION_FIELD_PREFIXES = new Set([
55
+ 'user',
56
+ 'users',
57
+ 'chain',
58
+ 'chains',
59
+ 'app',
60
+ 'apps',
61
+ 'token',
62
+ 'tokens',
63
+ 'label',
64
+ 'labels',
65
+ ]);
66
+ /**
67
+ * Parse and validate the --conditions JSON. Ensures it is an array of
68
+ * `{ field, op, value }` objects whose `field` is a typed path (e.g.
69
+ * `users.net_worth_usd`) — a bare name like `net_worth_usd` is silently
70
+ * dropped by the API, so it is rejected here. Exported for unit testing.
71
+ */
72
+ function parseSearchConditions(raw) {
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(raw);
76
+ }
77
+ catch {
78
+ throw new Error('--conditions must be a valid JSON array of FilterCondition objects');
79
+ }
80
+ if (!Array.isArray(parsed)) {
81
+ throw new Error('--conditions must be a valid JSON array of FilterCondition objects');
82
+ }
83
+ for (const cond of parsed) {
84
+ if (!cond || typeof cond !== 'object' || Array.isArray(cond)) {
85
+ throw new Error('--conditions: each entry must be an object with field, op, value');
86
+ }
87
+ const field = cond.field;
88
+ if (typeof field !== 'string' || field.length === 0) {
89
+ throw new Error('--conditions: each entry must have a non-empty string "field"');
90
+ }
91
+ if (!field.includes('.') || !CONDITION_FIELD_PREFIXES.has(field.split('.')[0])) {
92
+ throw new Error(`--conditions: field "${field}" must be a typed path — prefix it with ` +
93
+ 'users., chains., apps., tokens., or labels. ' +
94
+ '(a bare name is silently ignored by the API and returns the entire unfiltered dataset)');
95
+ }
96
+ }
97
+ return parsed;
98
+ }
42
99
  function searchProfilesRun(options) {
43
100
  (0, client_1.requireApiKey)();
44
101
  const client = (0, client_1.createClient)();
45
102
  const params = {};
46
103
  if (options.address)
47
104
  params.address = options.address;
48
- if (options.limit !== undefined)
49
- params.limit = options.limit;
50
- if (options.offset !== undefined)
51
- params.offset = options.offset;
105
+ if (options.page !== undefined)
106
+ params.page = options.page;
107
+ if (options.size !== undefined)
108
+ params.size = options.size;
52
109
  if (options.orderBy)
53
110
  params.order_by = options.orderBy;
54
111
  if (options.orderDir)
@@ -57,15 +114,10 @@ function searchProfilesRun(options) {
57
114
  params.expand = options.expand;
58
115
  let body;
59
116
  if (options.conditions) {
60
- try {
61
- const conditions = JSON.parse(options.conditions);
62
- if (!Array.isArray(conditions))
63
- throw new Error('not an array');
64
- body = { conditions, logic: options.logic ?? 'and' };
65
- }
66
- catch {
67
- throw new Error('--conditions must be valid JSON array of FilterCondition objects');
68
- }
117
+ body = {
118
+ conditions: parseSearchConditions(options.conditions),
119
+ logic: options.logic ?? 'and',
120
+ };
69
121
  }
70
122
  return client.request({ method: 'get', url: '/v0/profiles/', params, data: body });
71
123
  }
@@ -73,8 +125,8 @@ exports.profiles.command('search', {
73
125
  description: 'Search wallet profiles with optional filters',
74
126
  options: incur_1.z.object({
75
127
  address: incur_1.z.string().optional().describe('Filter by wallet address'),
76
- limit: incur_1.z.coerce.number().optional().describe('Max results to return'),
77
- offset: incur_1.z.coerce.number().optional().describe('Pagination offset'),
128
+ page: incur_1.z.coerce.number().optional().describe('Page number (1-indexed, default 1)'),
129
+ size: incur_1.z.coerce.number().optional().describe('Page size (default 100, max 1000)'),
78
130
  orderBy: incur_1.z
79
131
  .enum([
80
132
  'last_onchain',
@@ -96,35 +148,215 @@ exports.profiles.command('search', {
96
148
  conditions: incur_1.z
97
149
  .string()
98
150
  .optional()
99
- .describe('JSON array of FilterCondition objects for advanced filtering'),
151
+ .describe('JSON array of FilterCondition objects: [{"field","op","value"}]. ' +
152
+ 'The "field" MUST be a typed path — a bare name like "net_worth_usd" is silently ignored. ' +
153
+ 'Profile: users.net_worth_usd, users.volume, users.revenue, users.points. ' +
154
+ 'Engagement: users.device, users.browser, users.os, users.location, users.lifecycle. ' +
155
+ 'Socials: users.ens, users.farcaster, users.lens, etc. ' +
156
+ 'Chains: chains.balance or chains.{chain_id}.balance. ' +
157
+ 'Apps: apps.{app_id}.balance. Tokens: tokens.{address}.balance ' +
158
+ '(optional "scope":"any"|"protocol" + "appId"). Labels: labels.{tag_id}. ' +
159
+ 'op: eq, neq, gt, gte, lt, lte, in, nin.'),
100
160
  logic: incur_1.z
101
161
  .enum(['and', 'or'])
102
162
  .optional()
103
163
  .describe('Logic operator for combining conditions: "and" (default) or "or"'),
104
164
  }),
105
165
  examples: [
106
- { options: { limit: 10 }, description: 'List first 10 profiles' },
166
+ { options: { size: 10 }, description: 'List first 10 profiles' },
107
167
  {
108
- options: { orderBy: 'net_worth_usd', orderDir: 'desc', limit: 5 },
168
+ options: { orderBy: 'net_worth_usd', orderDir: 'desc', size: 5 },
109
169
  description: 'Top 5 profiles by net worth',
110
170
  },
171
+ {
172
+ options: { page: 2, size: 20 },
173
+ description: 'Get the second page of 20 profiles',
174
+ },
111
175
  {
112
176
  options: {
113
- conditions: '[{"field":"net_worth_usd","op":"gt","value":10000}]',
114
- limit: 20,
177
+ conditions: '[{"field":"users.net_worth_usd","op":"gt","value":10000}]',
178
+ size: 20,
115
179
  },
116
- description: 'Search profiles with net worth > 10000',
180
+ description: 'Search profiles with net worth > $10k',
117
181
  },
118
182
  {
119
183
  options: {
120
- conditions: '[{"field":"net_worth_usd","op":"gt","value":10000},{"field":"tx_count","op":"gt","value":50}]',
184
+ conditions: '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]',
121
185
  logic: 'or',
122
- limit: 20,
186
+ size: 20,
187
+ },
188
+ description: 'Search profiles matching either condition (net worth or volume)',
189
+ },
190
+ {
191
+ options: {
192
+ conditions: '[{"field":"chains.1.balance","op":"gt","value":1000}]',
193
+ size: 20,
123
194
  },
124
- description: 'Search profiles matching either condition',
195
+ description: 'Search profiles with > $1k balance on Ethereum (chain 1)',
125
196
  },
126
197
  ],
198
+ 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.',
127
199
  run({ args: _args, options }) {
128
200
  return searchProfilesRun(options);
129
201
  },
130
202
  });
203
+ function buildUpdateProfileBody(options) {
204
+ let body;
205
+ try {
206
+ body = JSON.parse(options.properties);
207
+ if (!body || typeof body !== 'object' || Array.isArray(body))
208
+ throw new Error('not an object');
209
+ }
210
+ catch {
211
+ throw new Error('--properties must be a JSON object of property keys');
212
+ }
213
+ if (Object.keys(body).length === 0) {
214
+ throw new Error('--properties must contain at least one key');
215
+ }
216
+ return body;
217
+ }
218
+ function updateProfileRun(address, options) {
219
+ (0, client_1.requireApiKey)();
220
+ const client = (0, client_1.createClient)();
221
+ return client.put(`/v0/profiles/${encodeURIComponent(address)}/properties`, buildUpdateProfileBody(options));
222
+ }
223
+ exports.profiles.command('update', {
224
+ description: 'Merge-update identity properties on a wallet profile',
225
+ args: incur_1.z.object({
226
+ address: incur_1.z.string().describe('Wallet address (0x... or ENS name)'),
227
+ }),
228
+ options: incur_1.z.object({
229
+ properties: incur_1.z
230
+ .string()
231
+ .describe('JSON object of properties to merge. Allowed keys: user_id, display_name, email, farcaster, discord, twitter, telegram, instagram, website, github, linkedin, facebook, tiktok, youtube, reddit, avatar, description, location, ens, lens, basenames, linea'),
232
+ }),
233
+ examples: [
234
+ {
235
+ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
236
+ options: {
237
+ properties: '{"display_name":"Vitalik","twitter":"VitalikButerin"}',
238
+ },
239
+ description: 'Set display name and Twitter handle',
240
+ },
241
+ {
242
+ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
243
+ options: { properties: '{"email":"alice@example.com"}' },
244
+ description: 'Set just the email',
245
+ },
246
+ ],
247
+ hint: 'Requires profiles:write scope on your API key. Only the listed keys are accepted; unknown keys are rejected.',
248
+ run({ args, options }) {
249
+ return updateProfileRun(args.address, options);
250
+ },
251
+ });
252
+ // ── Labels sub-resource ──
253
+ exports.profilesLabels = incur_1.Cli.create('labels', {
254
+ description: 'Manage labels on a wallet profile',
255
+ });
256
+ function buildCreateLabelBody(options) {
257
+ if (options.labels) {
258
+ try {
259
+ const parsed = JSON.parse(options.labels);
260
+ if (!Array.isArray(parsed) || parsed.length === 0)
261
+ throw new Error('not a non-empty array');
262
+ return parsed;
263
+ }
264
+ catch {
265
+ throw new Error('--labels must be a non-empty JSON array of UserLabelInput objects');
266
+ }
267
+ }
268
+ if (options.tagId) {
269
+ const single = { tag_id: options.tagId };
270
+ if (options.value)
271
+ single.value = options.value;
272
+ if (options.chainId)
273
+ single.chain_id = options.chainId;
274
+ return single;
275
+ }
276
+ throw new Error('Provide --tag-id (single label) or --labels (batch JSON array)');
277
+ }
278
+ function createProfileLabelRun(address, options) {
279
+ (0, client_1.requireApiKey)();
280
+ const client = (0, client_1.createClient)();
281
+ return client.post(`/v0/profiles/${encodeURIComponent(address)}/labels`, buildCreateLabelBody(options));
282
+ }
283
+ exports.profilesLabels.command('create', {
284
+ description: 'Upsert one or more labels on a wallet profile',
285
+ args: incur_1.z.object({
286
+ address: incur_1.z.string().describe('Wallet address (0x... or ENS name)'),
287
+ }),
288
+ options: incur_1.z.object({
289
+ tagId: incur_1.z
290
+ .string()
291
+ .optional()
292
+ .describe('Label identifier (e.g. "vip", "airdrop_eligible")'),
293
+ value: incur_1.z.string().optional().describe('Optional label value (e.g. tier name, country code)'),
294
+ chainId: incur_1.z.string().optional().describe('Optional chain identifier the label applies to'),
295
+ labels: incur_1.z
296
+ .string()
297
+ .optional()
298
+ .describe('JSON array of UserLabelInput objects for batch upsert'),
299
+ }),
300
+ examples: [
301
+ {
302
+ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
303
+ options: { tagId: 'vip' },
304
+ description: 'Tag a wallet as VIP',
305
+ },
306
+ {
307
+ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
308
+ options: { tagId: 'tier', value: 'gold', chainId: '1' },
309
+ description: 'Apply a tiered label scoped to a chain',
310
+ },
311
+ {
312
+ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
313
+ options: { labels: '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]' },
314
+ description: 'Apply multiple labels in one call',
315
+ },
316
+ ],
317
+ hint: 'Requires profiles:write scope on your API key.',
318
+ run({ args, options }) {
319
+ return createProfileLabelRun(args.address, options);
320
+ },
321
+ });
322
+ function buildDeleteLabelBody(options) {
323
+ if (!options.tagId) {
324
+ throw new Error('--tag-id is required');
325
+ }
326
+ const body = { tag_id: options.tagId };
327
+ if (options.chainId)
328
+ body.chain_id = options.chainId;
329
+ return body;
330
+ }
331
+ function deleteProfileLabelRun(address, options) {
332
+ (0, client_1.requireApiKey)();
333
+ const client = (0, client_1.createClient)();
334
+ return client.delete(`/v0/profiles/${encodeURIComponent(address)}/labels`, { data: buildDeleteLabelBody(options) });
335
+ }
336
+ exports.profilesLabels.command('delete', {
337
+ description: 'Delete a label from a wallet profile',
338
+ args: incur_1.z.object({
339
+ address: incur_1.z.string().describe('Wallet address (0x... or ENS name)'),
340
+ }),
341
+ options: incur_1.z.object({
342
+ tagId: incur_1.z.string().describe('Label identifier to delete'),
343
+ chainId: incur_1.z.string().optional().describe('Optional chain identifier to scope the deletion'),
344
+ }),
345
+ examples: [
346
+ {
347
+ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
348
+ options: { tagId: 'vip' },
349
+ description: 'Remove the vip label',
350
+ },
351
+ {
352
+ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
353
+ options: { tagId: 'tier', chainId: '1' },
354
+ description: 'Remove a chain-scoped label',
355
+ },
356
+ ],
357
+ hint: 'Requires profiles:write scope on your API key.',
358
+ run({ args, options }) {
359
+ return deleteProfileLabelRun(args.address, options);
360
+ },
361
+ });
362
+ exports.profiles.command(exports.profilesLabels);
@@ -1 +1,3 @@
1
+ import { Cli } from 'incur';
2
+ export declare const query: Cli.Cli<{}, undefined, undefined>;
1
3
  export declare function queryRunRun(sql: string): Promise<import("axios").AxiosResponse<any, any, {}>>;
@@ -1,9 +1,34 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.query = void 0;
3
4
  exports.queryRunRun = queryRunRun;
5
+ const incur_1 = require("incur");
4
6
  const client_1 = require("../lib/client");
7
+ exports.query = incur_1.Cli.create('query', {
8
+ description: 'SQL analytics query commands',
9
+ });
5
10
  function queryRunRun(sql) {
6
11
  (0, client_1.requireApiKey)();
7
12
  const client = (0, client_1.createClient)();
8
13
  return client.post('/v0/query/', { query: sql });
9
14
  }
15
+ exports.query.command('run', {
16
+ description: 'Run a SQL query against your Formo analytics data',
17
+ args: incur_1.z.object({
18
+ sql: incur_1.z.string().describe('SQL query string to execute'),
19
+ }),
20
+ examples: [
21
+ {
22
+ args: { sql: 'SELECT count(*) FROM events' },
23
+ description: 'Count all events',
24
+ },
25
+ {
26
+ args: { sql: 'SELECT address, net_worth_usd FROM wallet_profiles ORDER BY net_worth_usd DESC LIMIT 10' },
27
+ description: 'Top 10 wallets by net worth',
28
+ },
29
+ ],
30
+ hint: 'Requires query:read scope on your API key.',
31
+ run({ args }) {
32
+ return queryRunRun(args.sql);
33
+ },
34
+ });
@@ -5,5 +5,9 @@ export interface CreateSegmentOptions {
5
5
  title: string;
6
6
  filterSets: string;
7
7
  }
8
+ export declare function buildCreateSegmentBody(options: CreateSegmentOptions): {
9
+ title: string;
10
+ filterSets: unknown;
11
+ };
8
12
  export declare function createSegmentRun(options: CreateSegmentOptions): Promise<import("axios").AxiosResponse<any, any, {}>>;
9
13
  export declare function deleteSegmentRun(segmentId: string): Promise<import("axios").AxiosResponse<any, any, {}>>;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.segments = void 0;
4
4
  exports.listSegmentsRun = listSegmentsRun;
5
+ exports.buildCreateSegmentBody = buildCreateSegmentBody;
5
6
  exports.createSegmentRun = createSegmentRun;
6
7
  exports.deleteSegmentRun = deleteSegmentRun;
7
8
  const incur_1 = require("incur");
@@ -23,20 +24,23 @@ exports.segments.command('list', {
23
24
  return listSegmentsRun();
24
25
  },
25
26
  });
26
- function createSegmentRun(options) {
27
- (0, client_1.requireApiKey)();
28
- const client = (0, client_1.createClient)();
27
+ function buildCreateSegmentBody(options) {
29
28
  let parsedFilterSets;
30
29
  try {
31
30
  parsedFilterSets = JSON.parse(options.filterSets);
32
31
  }
33
32
  catch {
34
- throw new Error('--filterSets must be a valid JSON array');
33
+ throw new Error('--filter-sets must be a valid JSON array');
35
34
  }
36
- return client.post('/v0/segments/', {
35
+ return {
37
36
  title: options.title,
38
37
  filterSets: parsedFilterSets,
39
- });
38
+ };
39
+ }
40
+ function createSegmentRun(options) {
41
+ (0, client_1.requireApiKey)();
42
+ const client = (0, client_1.createClient)();
43
+ return client.post('/v0/segments/', buildCreateSegmentBody(options));
40
44
  }
41
45
  exports.segments.command('create', {
42
46
  description: 'Create a new user segment',
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { Cli } from 'incur';
2
+ import { Cli } from "incur";
3
3
  declare const cli: Cli.Cli<{}, undefined, undefined>;
4
4
  export default cli;