@formo/cli 1.0.0 → 1.1.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/LICENSE +21 -0
- package/README.md +111 -20
- package/dist/commands/alerts.d.ts +16 -3
- package/dist/commands/alerts.js +94 -24
- package/dist/commands/analytics.js +2 -2
- 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 +28 -2
- package/dist/commands/profiles.js +185 -17
- package/dist/commands/query.js +5 -1
- package/dist/commands/segments.d.ts +6 -2
- package/dist/commands/segments.js +19 -5
- package/dist/index.js +11 -4
- 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 -2
|
@@ -1,26 +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
5
|
exports.parseSearchConditions = parseSearchConditions;
|
|
6
6
|
exports.searchProfilesRun = searchProfilesRun;
|
|
7
7
|
exports.buildUpdateProfileBody = buildUpdateProfileBody;
|
|
8
8
|
exports.updateProfileRun = updateProfileRun;
|
|
9
|
+
exports.buildBatchUpdateProfilesBody = buildBatchUpdateProfilesBody;
|
|
10
|
+
exports.batchUpdateProfilesRun = batchUpdateProfilesRun;
|
|
9
11
|
exports.buildCreateLabelBody = buildCreateLabelBody;
|
|
10
12
|
exports.createProfileLabelRun = createProfileLabelRun;
|
|
13
|
+
exports.buildBatchCreateLabelsBody = buildBatchCreateLabelsBody;
|
|
14
|
+
exports.batchCreateProfileLabelsRun = batchCreateProfileLabelsRun;
|
|
11
15
|
exports.buildDeleteLabelBody = buildDeleteLabelBody;
|
|
12
16
|
exports.deleteProfileLabelRun = deleteProfileLabelRun;
|
|
13
17
|
const incur_1 = require("incur");
|
|
14
18
|
const client_1 = require("../lib/client");
|
|
19
|
+
const json_1 = require("../lib/json");
|
|
15
20
|
exports.profiles = incur_1.Cli.create('profiles', {
|
|
16
21
|
description: 'Wallet profile commands',
|
|
17
22
|
});
|
|
18
|
-
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 = {}) {
|
|
19
78
|
(0, client_1.requireApiKey)();
|
|
20
79
|
const client = (0, client_1.createClient)();
|
|
80
|
+
const options = typeof optionsOrExpand === 'string'
|
|
81
|
+
? { expand: optionsOrExpand }
|
|
82
|
+
: optionsOrExpand;
|
|
21
83
|
const params = {};
|
|
22
|
-
if (expand)
|
|
23
|
-
params.expand = expand;
|
|
84
|
+
if (options.expand)
|
|
85
|
+
params.expand = options.expand;
|
|
86
|
+
addLifecycleThresholdParams(params, options);
|
|
24
87
|
return client.get(`/v0/profiles/${encodeURIComponent(address)}`, { params });
|
|
25
88
|
}
|
|
26
89
|
exports.profiles.command('get', {
|
|
@@ -33,6 +96,7 @@ exports.profiles.command('get', {
|
|
|
33
96
|
.string()
|
|
34
97
|
.optional()
|
|
35
98
|
.describe('Comma-separated list of fields to expand: apps,chains,tokens,labels'),
|
|
99
|
+
...lifecycleThresholdOptions,
|
|
36
100
|
}),
|
|
37
101
|
examples: [
|
|
38
102
|
{ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, description: 'Get a wallet profile' },
|
|
@@ -44,7 +108,7 @@ exports.profiles.command('get', {
|
|
|
44
108
|
],
|
|
45
109
|
hint: 'Requires profiles:read scope on your API key.',
|
|
46
110
|
run({ args, options }) {
|
|
47
|
-
return getProfileRun(args.address, options
|
|
111
|
+
return getProfileRun(args.address, options);
|
|
48
112
|
},
|
|
49
113
|
});
|
|
50
114
|
// Accepted first segments for a FilterCondition `field`, mirroring the API's
|
|
@@ -102,6 +166,8 @@ function searchProfilesRun(options) {
|
|
|
102
166
|
const params = {};
|
|
103
167
|
if (options.address)
|
|
104
168
|
params.address = options.address;
|
|
169
|
+
if (options.search)
|
|
170
|
+
params.search = options.search;
|
|
105
171
|
if (options.page !== undefined)
|
|
106
172
|
params.page = options.page;
|
|
107
173
|
if (options.size !== undefined)
|
|
@@ -112,6 +178,7 @@ function searchProfilesRun(options) {
|
|
|
112
178
|
params.order_dir = options.orderDir;
|
|
113
179
|
if (options.expand)
|
|
114
180
|
params.expand = options.expand;
|
|
181
|
+
addLifecycleThresholdParams(params, options);
|
|
115
182
|
let body;
|
|
116
183
|
if (options.conditions) {
|
|
117
184
|
body = {
|
|
@@ -119,12 +186,19 @@ function searchProfilesRun(options) {
|
|
|
119
186
|
logic: options.logic ?? 'and',
|
|
120
187
|
};
|
|
121
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.
|
|
122
195
|
return client.request({ method: 'get', url: '/v0/profiles/', params, data: body });
|
|
123
196
|
}
|
|
124
197
|
exports.profiles.command('search', {
|
|
125
198
|
description: 'Search wallet profiles with optional filters',
|
|
126
199
|
options: incur_1.z.object({
|
|
127
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'),
|
|
128
202
|
page: incur_1.z.coerce.number().optional().describe('Page number (1-indexed, default 1)'),
|
|
129
203
|
size: incur_1.z.coerce.number().optional().describe('Page size (default 100, max 1000)'),
|
|
130
204
|
orderBy: incur_1.z
|
|
@@ -156,11 +230,14 @@ exports.profiles.command('search', {
|
|
|
156
230
|
'Chains: chains.balance or chains.{chain_id}.balance. ' +
|
|
157
231
|
'Apps: apps.{app_id}.balance. Tokens: tokens.{address}.balance ' +
|
|
158
232
|
'(optional "scope":"any"|"protocol" + "appId"). Labels: labels.{tag_id}. ' +
|
|
159
|
-
'op: eq, neq, gt, gte, lt, lte, in, nin
|
|
233
|
+
'op: eq, neq, gt, gte, lt, lte, in, nin, contains, notEmpty, isEmpty ' +
|
|
234
|
+
'(contains = substring, social fields only; notEmpty/isEmpty = value-less existence checks on string fields). ' +
|
|
235
|
+
'Long-form spellings (equals, notEquals, greater, greaterOrEqual, less, lessOrEqual, notIn, includes) are retired; the API rejects them with a 400 naming the token.'),
|
|
160
236
|
logic: incur_1.z
|
|
161
237
|
.enum(['and', 'or'])
|
|
162
238
|
.optional()
|
|
163
239
|
.describe('Logic operator for combining conditions: "and" (default) or "or"'),
|
|
240
|
+
...lifecycleThresholdOptions,
|
|
164
241
|
}),
|
|
165
242
|
examples: [
|
|
166
243
|
{ options: { size: 10 }, description: 'List first 10 profiles' },
|
|
@@ -201,15 +278,7 @@ exports.profiles.command('search', {
|
|
|
201
278
|
},
|
|
202
279
|
});
|
|
203
280
|
function buildUpdateProfileBody(options) {
|
|
204
|
-
|
|
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
|
-
}
|
|
281
|
+
const body = (0, json_1.parseJsonObject)(options.properties, '--properties');
|
|
213
282
|
if (Object.keys(body).length === 0) {
|
|
214
283
|
throw new Error('--properties must contain at least one key');
|
|
215
284
|
}
|
|
@@ -249,6 +318,46 @@ exports.profiles.command('update', {
|
|
|
249
318
|
return updateProfileRun(args.address, options);
|
|
250
319
|
},
|
|
251
320
|
});
|
|
321
|
+
function buildBatchUpdateProfilesBody(options) {
|
|
322
|
+
const rows = (0, json_1.parseJsonArrayOfObjects)(options.rows, '--rows');
|
|
323
|
+
if (rows.length === 0) {
|
|
324
|
+
throw new Error('--rows must contain at least one item');
|
|
325
|
+
}
|
|
326
|
+
for (const row of rows) {
|
|
327
|
+
if (typeof row.address !== 'string' || row.address.length === 0) {
|
|
328
|
+
throw new Error('--rows entries must each include a non-empty string address');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return rows;
|
|
332
|
+
}
|
|
333
|
+
function batchUpdateProfilesRun(options) {
|
|
334
|
+
(0, client_1.requireApiKey)();
|
|
335
|
+
const client = (0, client_1.createClient)();
|
|
336
|
+
return client.post('/v0/profiles/properties', buildBatchUpdateProfilesBody(options));
|
|
337
|
+
}
|
|
338
|
+
exports.profilesProperties = incur_1.Cli.create('properties', {
|
|
339
|
+
description: 'Manage first-party profile properties in bulk',
|
|
340
|
+
});
|
|
341
|
+
exports.profilesProperties.command('batch', {
|
|
342
|
+
description: 'Batch update first-party profile properties for up to 100 wallets',
|
|
343
|
+
options: incur_1.z.object({
|
|
344
|
+
rows: incur_1.z
|
|
345
|
+
.string()
|
|
346
|
+
.describe('JSON array of flat {address,...properties} objects. ENS names are not resolved in batch requests.'),
|
|
347
|
+
}),
|
|
348
|
+
examples: [
|
|
349
|
+
{
|
|
350
|
+
options: {
|
|
351
|
+
rows: '[{"address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","display_name":"alice.eth","email":"alice@example.com"}]',
|
|
352
|
+
},
|
|
353
|
+
description: 'Batch set display names and emails',
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
hint: 'Requires profiles:write scope on your API key. Unknown keys are ignored by the API; invalid rows are quarantined.',
|
|
357
|
+
run({ options }) {
|
|
358
|
+
return batchUpdateProfilesRun(options);
|
|
359
|
+
},
|
|
360
|
+
});
|
|
252
361
|
// ── Labels sub-resource ──
|
|
253
362
|
exports.profilesLabels = incur_1.Cli.create('labels', {
|
|
254
363
|
description: 'Manage labels on a wallet profile',
|
|
@@ -267,10 +376,15 @@ function buildCreateLabelBody(options) {
|
|
|
267
376
|
}
|
|
268
377
|
if (options.tagId) {
|
|
269
378
|
const single = { tag_id: options.tagId };
|
|
270
|
-
if (options.value)
|
|
379
|
+
if (options.value !== undefined)
|
|
271
380
|
single.value = options.value;
|
|
272
381
|
if (options.chainId)
|
|
273
382
|
single.chain_id = options.chainId;
|
|
383
|
+
if (options.timestamp)
|
|
384
|
+
single.timestamp = options.timestamp;
|
|
385
|
+
if (options.isDeleted !== undefined) {
|
|
386
|
+
single._is_deleted = options.isDeleted ? 1 : 0;
|
|
387
|
+
}
|
|
274
388
|
return single;
|
|
275
389
|
}
|
|
276
390
|
throw new Error('Provide --tag-id (single label) or --labels (batch JSON array)');
|
|
@@ -292,10 +406,18 @@ exports.profilesLabels.command('create', {
|
|
|
292
406
|
.describe('Label identifier (e.g. "vip", "airdrop_eligible")'),
|
|
293
407
|
value: incur_1.z.string().optional().describe('Optional label value (e.g. tier name, country code)'),
|
|
294
408
|
chainId: incur_1.z.string().optional().describe('Optional chain identifier the label applies to'),
|
|
409
|
+
timestamp: incur_1.z
|
|
410
|
+
.string()
|
|
411
|
+
.optional()
|
|
412
|
+
.describe('Optional historical ISO-8601 timestamp for the label row'),
|
|
413
|
+
isDeleted: incur_1.z
|
|
414
|
+
.boolean()
|
|
415
|
+
.optional()
|
|
416
|
+
.describe('Set true with --timestamp to backfill a label removal tombstone'),
|
|
295
417
|
labels: incur_1.z
|
|
296
418
|
.string()
|
|
297
419
|
.optional()
|
|
298
|
-
.describe('JSON array of UserLabelInput objects for
|
|
420
|
+
.describe('JSON array of UserLabelInput objects for this wallet'),
|
|
299
421
|
}),
|
|
300
422
|
examples: [
|
|
301
423
|
{
|
|
@@ -308,6 +430,11 @@ exports.profilesLabels.command('create', {
|
|
|
308
430
|
options: { tagId: 'tier', value: 'gold', chainId: '1' },
|
|
309
431
|
description: 'Apply a tiered label scoped to a chain',
|
|
310
432
|
},
|
|
433
|
+
{
|
|
434
|
+
args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
|
|
435
|
+
options: { tagId: 'tier', timestamp: '2024-03-15T00:00:00.000Z', isDeleted: true },
|
|
436
|
+
description: 'Backfill a historical label removal',
|
|
437
|
+
},
|
|
311
438
|
{
|
|
312
439
|
args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
|
|
313
440
|
options: { labels: '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]' },
|
|
@@ -319,6 +446,46 @@ exports.profilesLabels.command('create', {
|
|
|
319
446
|
return createProfileLabelRun(args.address, options);
|
|
320
447
|
},
|
|
321
448
|
});
|
|
449
|
+
function buildBatchCreateLabelsBody(options) {
|
|
450
|
+
const labels = (0, json_1.parseJsonArrayOfObjects)(options.labels, '--labels');
|
|
451
|
+
if (labels.length === 0) {
|
|
452
|
+
throw new Error('--labels must contain at least one item');
|
|
453
|
+
}
|
|
454
|
+
for (const label of labels) {
|
|
455
|
+
if (typeof label.address !== 'string' || label.address.length === 0) {
|
|
456
|
+
throw new Error('--labels entries must each include a non-empty string address');
|
|
457
|
+
}
|
|
458
|
+
if (typeof label.tag_id !== 'string' || label.tag_id.length === 0) {
|
|
459
|
+
throw new Error('--labels entries must each include a non-empty string tag_id');
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return labels;
|
|
463
|
+
}
|
|
464
|
+
function batchCreateProfileLabelsRun(options) {
|
|
465
|
+
(0, client_1.requireApiKey)();
|
|
466
|
+
const client = (0, client_1.createClient)();
|
|
467
|
+
return client.post('/v0/profiles/labels', buildBatchCreateLabelsBody(options));
|
|
468
|
+
}
|
|
469
|
+
exports.profilesLabels.command('batch', {
|
|
470
|
+
description: 'Batch upsert labels across up to 100 wallets',
|
|
471
|
+
options: incur_1.z.object({
|
|
472
|
+
labels: incur_1.z
|
|
473
|
+
.string()
|
|
474
|
+
.describe('JSON array of {address,tag_id,value?,chain_id?,timestamp?,_is_deleted?} objects'),
|
|
475
|
+
}),
|
|
476
|
+
examples: [
|
|
477
|
+
{
|
|
478
|
+
options: {
|
|
479
|
+
labels: '[{"address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","tag_id":"vip","value":"tier-1"}]',
|
|
480
|
+
},
|
|
481
|
+
description: 'Batch upsert labels for multiple wallets',
|
|
482
|
+
},
|
|
483
|
+
],
|
|
484
|
+
hint: 'Requires profiles:write scope on your API key. ENS names are not resolved in batch requests.',
|
|
485
|
+
run({ options }) {
|
|
486
|
+
return batchCreateProfileLabelsRun(options);
|
|
487
|
+
},
|
|
488
|
+
});
|
|
322
489
|
function buildDeleteLabelBody(options) {
|
|
323
490
|
if (!options.tagId) {
|
|
324
491
|
throw new Error('--tag-id is required');
|
|
@@ -359,4 +526,5 @@ exports.profilesLabels.command('delete', {
|
|
|
359
526
|
return deleteProfileLabelRun(args.address, options);
|
|
360
527
|
},
|
|
361
528
|
});
|
|
529
|
+
exports.profiles.command(exports.profilesProperties);
|
|
362
530
|
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,24 +10,38 @@ 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
47
|
throw new Error('--filter-sets must be a valid JSON array');
|
package/dist/index.js
CHANGED
|
@@ -7,22 +7,23 @@ const analytics_1 = require("./commands/analytics");
|
|
|
7
7
|
const boards_1 = require("./commands/boards");
|
|
8
8
|
const charts_1 = require("./commands/charts");
|
|
9
9
|
const contracts_1 = require("./commands/contracts");
|
|
10
|
+
const events_1 = require("./commands/events");
|
|
10
11
|
const import_1 = require("./commands/import");
|
|
11
12
|
const profiles_1 = require("./commands/profiles");
|
|
12
13
|
const query_1 = require("./commands/query");
|
|
13
14
|
const segments_1 = require("./commands/segments");
|
|
15
|
+
const client_1 = require("./lib/client");
|
|
14
16
|
const config_1 = require("./lib/config");
|
|
15
17
|
const ui_1 = require("./lib/ui");
|
|
16
18
|
const DASHBOARD_URL = "https://app.formo.so";
|
|
17
19
|
const DOCS_URL = "https://docs.formo.so";
|
|
18
|
-
const API_BASE_URL = "https://api.formo.so";
|
|
19
20
|
function loginGuide() {
|
|
20
21
|
return [
|
|
21
22
|
"",
|
|
22
23
|
ui_1.color.boldGreen("How to get your API key:"),
|
|
23
24
|
"",
|
|
24
25
|
` ${ui_1.color.white("1.")} Go to ${ui_1.color.cyan(DASHBOARD_URL)}`,
|
|
25
|
-
` ${ui_1.color.white("2.")} Navigate to ${ui_1.color.bold("Settings")} → ${ui_1.color.bold("API
|
|
26
|
+
` ${ui_1.color.white("2.")} Navigate to ${ui_1.color.bold("Settings")} → ${ui_1.color.bold("API")}`,
|
|
26
27
|
` ${ui_1.color.white("3.")} Click ${ui_1.color.bold('"Create API Key"')} and copy the key`,
|
|
27
28
|
` ${ui_1.color.white("4.")} Run:`,
|
|
28
29
|
"",
|
|
@@ -38,7 +39,7 @@ function loginGuide() {
|
|
|
38
39
|
}
|
|
39
40
|
async function validateAndFetchWorkspace(apiKey) {
|
|
40
41
|
try {
|
|
41
|
-
const res = await fetch(`${
|
|
42
|
+
const res = await fetch(`${(0, client_1.getApiBaseUrl)()}/api/validate-api-key`, {
|
|
42
43
|
method: "POST",
|
|
43
44
|
headers: { "Content-Type": "application/json" },
|
|
44
45
|
body: JSON.stringify({ apiKey }),
|
|
@@ -58,7 +59,7 @@ async function validateAndFetchWorkspace(apiKey) {
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
const cli = incur_1.Cli.create("formo", {
|
|
61
|
-
version: "0.
|
|
62
|
+
version: "1.0.1",
|
|
62
63
|
description: "Formo API CLI — Web3 analytics from the terminal",
|
|
63
64
|
sync: {
|
|
64
65
|
suggestions: [
|
|
@@ -72,10 +73,15 @@ const cli = incur_1.Cli.create("formo", {
|
|
|
72
73
|
"list all project alerts",
|
|
73
74
|
"create an alert for high-value transactions",
|
|
74
75
|
"list charts in a board",
|
|
76
|
+
"create a line chart in a board",
|
|
77
|
+
"move or duplicate a dashboard chart",
|
|
75
78
|
"list all tracked contracts",
|
|
76
79
|
"register a new smart contract",
|
|
77
80
|
"list user segments",
|
|
78
81
|
"import wallet addresses",
|
|
82
|
+
"batch update profile properties with profiles properties batch",
|
|
83
|
+
"batch upsert labels for wallets",
|
|
84
|
+
"send raw analytics events",
|
|
79
85
|
],
|
|
80
86
|
},
|
|
81
87
|
});
|
|
@@ -243,6 +249,7 @@ cli.command(alerts_1.alerts);
|
|
|
243
249
|
cli.command(boards_1.boards);
|
|
244
250
|
cli.command(charts_1.charts);
|
|
245
251
|
cli.command(contracts_1.contracts);
|
|
252
|
+
cli.command(events_1.events);
|
|
246
253
|
cli.command(segments_1.segments);
|
|
247
254
|
cli.command(import_1.importCmd);
|
|
248
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 };
|
package/dist/lib/client.js
CHANGED
|
@@ -3,12 +3,23 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.DEFAULT_EVENTS_BASE_URL = exports.DEFAULT_API_BASE_URL = void 0;
|
|
7
|
+
exports.getApiBaseUrl = getApiBaseUrl;
|
|
8
|
+
exports.getEventsBaseUrl = getEventsBaseUrl;
|
|
6
9
|
exports.parseApiError = parseApiError;
|
|
10
|
+
exports.createEventsClient = createEventsClient;
|
|
7
11
|
exports.requireApiKey = requireApiKey;
|
|
8
12
|
exports.createClient = createClient;
|
|
9
13
|
const axios_1 = __importDefault(require("axios"));
|
|
10
14
|
const config_1 = require("./config");
|
|
11
|
-
|
|
15
|
+
exports.DEFAULT_API_BASE_URL = 'https://api.formo.so';
|
|
16
|
+
exports.DEFAULT_EVENTS_BASE_URL = 'https://events.formo.so';
|
|
17
|
+
function getApiBaseUrl() {
|
|
18
|
+
return process.env.FORMO_API_BASE_URL ?? exports.DEFAULT_API_BASE_URL;
|
|
19
|
+
}
|
|
20
|
+
function getEventsBaseUrl() {
|
|
21
|
+
return process.env.FORMO_EVENTS_BASE_URL ?? exports.DEFAULT_EVENTS_BASE_URL;
|
|
22
|
+
}
|
|
12
23
|
/**
|
|
13
24
|
* Translate an AxiosError into a thrown Error with the API's structured
|
|
14
25
|
* `{ error: { code, message, doc_url, param, details } }` envelope decoded
|
|
@@ -25,6 +36,12 @@ function parseApiError(error) {
|
|
|
25
36
|
parts.push(apiError?.code ? `[${apiError.code}] ${baseMessage}` : baseMessage);
|
|
26
37
|
if (apiError?.param)
|
|
27
38
|
parts.push(`Param: ${apiError.param}`);
|
|
39
|
+
if (apiError?.details && Object.keys(apiError.details).length > 0) {
|
|
40
|
+
const details = Object.entries(apiError.details)
|
|
41
|
+
.map(([key, value]) => `${key}: ${String(value)}`)
|
|
42
|
+
.join('; ');
|
|
43
|
+
parts.push(`Details: ${details}`);
|
|
44
|
+
}
|
|
28
45
|
if (apiError?.doc_url)
|
|
29
46
|
parts.push(`Docs: ${apiError.doc_url}`);
|
|
30
47
|
const message = parts.join('\n ');
|
|
@@ -37,9 +54,9 @@ function parseApiError(error) {
|
|
|
37
54
|
transportCode: error.code,
|
|
38
55
|
});
|
|
39
56
|
}
|
|
40
|
-
function createClient() {
|
|
41
|
-
const apiKey = (0, config_1.getApiKey)();
|
|
42
|
-
const baseURL =
|
|
57
|
+
function createClient(options = {}) {
|
|
58
|
+
const apiKey = options.apiKey ?? (0, config_1.getApiKey)();
|
|
59
|
+
const baseURL = options.baseURL ?? getApiBaseUrl();
|
|
43
60
|
const instance = axios_1.default.create({
|
|
44
61
|
baseURL,
|
|
45
62
|
timeout: 30000,
|
|
@@ -53,6 +70,12 @@ function createClient() {
|
|
|
53
70
|
});
|
|
54
71
|
return instance;
|
|
55
72
|
}
|
|
73
|
+
function createEventsClient(writeKey) {
|
|
74
|
+
if (!writeKey) {
|
|
75
|
+
throw new Error('No event write key configured. Pass --write-key or set FORMO_WRITE_KEY.');
|
|
76
|
+
}
|
|
77
|
+
return createClient({ baseURL: getEventsBaseUrl(), apiKey: writeKey });
|
|
78
|
+
}
|
|
56
79
|
function requireApiKey() {
|
|
57
80
|
if (!(0, config_1.getApiKey)()) {
|
|
58
81
|
throw new Error('No API key configured. Run `formo login <apiKey>` or set FORMO_API_KEY env var.');
|
package/dist/lib/config.js
CHANGED
|
@@ -24,10 +24,17 @@ function readConfig() {
|
|
|
24
24
|
function saveConfig(updates) {
|
|
25
25
|
const existing = readConfig();
|
|
26
26
|
const merged = { ...existing, ...updates };
|
|
27
|
+
// The `mode` option on mkdir/writeFile is honored ONLY when the path is
|
|
28
|
+
// newly created. A pre-existing dir/file (older CLI version, dotfile-sync
|
|
29
|
+
// tool, another app under ~/.config) keeps its old, possibly
|
|
30
|
+
// group/world-readable perms — leaking the plaintext API key on a
|
|
31
|
+
// multi-user host. chmod unconditionally so 0o700/0o600 always holds.
|
|
27
32
|
fs_1.default.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
33
|
+
fs_1.default.chmodSync(CONFIG_DIR, 0o700);
|
|
28
34
|
fs_1.default.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), {
|
|
29
35
|
mode: 0o600,
|
|
30
36
|
});
|
|
37
|
+
fs_1.default.chmodSync(CONFIG_FILE, 0o600);
|
|
31
38
|
}
|
|
32
39
|
function clearConfig() {
|
|
33
40
|
try {
|
|
@@ -35,6 +42,9 @@ function clearConfig() {
|
|
|
35
42
|
fs_1.default.writeFileSync(CONFIG_FILE, JSON.stringify({}, null, 2), {
|
|
36
43
|
mode: 0o600,
|
|
37
44
|
});
|
|
45
|
+
// Same create-only-mode caveat as saveConfig: enforce 0o600 on the
|
|
46
|
+
// already-existing file so the cleared config can't be left readable.
|
|
47
|
+
fs_1.default.chmodSync(CONFIG_FILE, 0o600);
|
|
38
48
|
}
|
|
39
49
|
}
|
|
40
50
|
catch {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function parseJson(raw: string, flagName: string): unknown;
|
|
2
|
+
export declare function parseJsonObject(raw: string, flagName: string): Record<string, unknown>;
|
|
3
|
+
export declare function parseJsonArray(raw: string, flagName: string): unknown[];
|
|
4
|
+
export declare function parseJsonArrayOfObjects(raw: string, flagName: string): Record<string, unknown>[];
|
|
5
|
+
export declare function parseStringArray(raw: string, flagName: string): string[];
|
package/dist/lib/json.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseJson = parseJson;
|
|
4
|
+
exports.parseJsonObject = parseJsonObject;
|
|
5
|
+
exports.parseJsonArray = parseJsonArray;
|
|
6
|
+
exports.parseJsonArrayOfObjects = parseJsonArrayOfObjects;
|
|
7
|
+
exports.parseStringArray = parseStringArray;
|
|
8
|
+
function parseJson(raw, flagName) {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(raw);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new Error(`${flagName} must be valid JSON`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function parseJsonObject(raw, flagName) {
|
|
17
|
+
const parsed = parseJson(raw, flagName);
|
|
18
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
19
|
+
throw new Error(`${flagName} must be a valid JSON object`);
|
|
20
|
+
}
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
function parseJsonArray(raw, flagName) {
|
|
24
|
+
const parsed = parseJson(raw, flagName);
|
|
25
|
+
if (!Array.isArray(parsed)) {
|
|
26
|
+
throw new Error(`${flagName} must be a valid JSON array`);
|
|
27
|
+
}
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
function parseJsonArrayOfObjects(raw, flagName) {
|
|
31
|
+
const parsed = parseJsonArray(raw, flagName);
|
|
32
|
+
if (parsed.some((item) => !item || typeof item !== 'object' || Array.isArray(item))) {
|
|
33
|
+
throw new Error(`${flagName} must be a valid JSON array of objects`);
|
|
34
|
+
}
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
function parseStringArray(raw, flagName) {
|
|
38
|
+
const value = raw.trim();
|
|
39
|
+
if (value.startsWith('[')) {
|
|
40
|
+
const parsed = parseJsonArray(value, flagName);
|
|
41
|
+
if (parsed.some((item) => typeof item !== 'string')) {
|
|
42
|
+
throw new Error(`${flagName} must be a JSON array of strings`);
|
|
43
|
+
}
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
const parts = value
|
|
47
|
+
.split(',')
|
|
48
|
+
.map((part) => part.trim())
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
if (parts.length === 0) {
|
|
51
|
+
throw new Error(`${flagName} must contain at least one value`);
|
|
52
|
+
}
|
|
53
|
+
return parts;
|
|
54
|
+
}
|