@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.
- package/README.md +154 -40
- package/dist/commands/alerts.d.ts +16 -3
- package/dist/commands/alerts.js +94 -24
- package/dist/commands/analytics.d.ts +25 -0
- package/dist/commands/analytics.js +153 -0
- package/dist/commands/boards.d.ts +11 -2
- package/dist/commands/boards.js +49 -17
- package/dist/commands/charts.d.ts +30 -3
- package/dist/commands/charts.js +297 -33
- package/dist/commands/contracts.d.ts +17 -13
- package/dist/commands/contracts.js +165 -53
- package/dist/commands/events.d.ts +9 -0
- package/dist/commands/events.js +64 -0
- package/dist/commands/import.d.ts +8 -4
- package/dist/commands/import.js +33 -11
- package/dist/commands/profiles.d.ts +35 -2
- package/dist/commands/profiles.js +260 -32
- package/dist/commands/query.js +5 -1
- package/dist/commands/segments.d.ts +6 -2
- package/dist/commands/segments.js +20 -6
- package/dist/index.js +15 -3
- package/dist/lib/client.d.ts +10 -1
- package/dist/lib/client.js +27 -4
- package/dist/lib/config.js +10 -0
- package/dist/lib/json.d.ts +5 -0
- package/dist/lib/json.js +54 -0
- package/dist/lib/sql.d.ts +31 -0
- package/dist/lib/sql.js +151 -0
- package/package.json +3 -3
|
@@ -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
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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 >
|
|
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":"
|
|
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
|
-
|
|
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 --
|
|
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
|
|
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('--
|
|
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);
|
package/dist/commands/query.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
14
|
-
|
|
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('--
|
|
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(`${
|
|
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.
|
|
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)
|
package/dist/lib/client.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 };
|