@formo/cli 1.0.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 +89 -15
- package/dist/commands/alerts.d.ts +16 -3
- package/dist/commands/alerts.js +94 -24
- 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 +182 -16
- 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 +10 -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 +1 -1
|
@@ -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
|
|
@@ -161,6 +235,7 @@ exports.profiles.command('search', {
|
|
|
161
235
|
.enum(['and', 'or'])
|
|
162
236
|
.optional()
|
|
163
237
|
.describe('Logic operator for combining conditions: "and" (default) or "or"'),
|
|
238
|
+
...lifecycleThresholdOptions,
|
|
164
239
|
}),
|
|
165
240
|
examples: [
|
|
166
241
|
{ options: { size: 10 }, description: 'List first 10 profiles' },
|
|
@@ -201,15 +276,7 @@ exports.profiles.command('search', {
|
|
|
201
276
|
},
|
|
202
277
|
});
|
|
203
278
|
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
|
-
}
|
|
279
|
+
const body = (0, json_1.parseJsonObject)(options.properties, '--properties');
|
|
213
280
|
if (Object.keys(body).length === 0) {
|
|
214
281
|
throw new Error('--properties must contain at least one key');
|
|
215
282
|
}
|
|
@@ -249,6 +316,46 @@ exports.profiles.command('update', {
|
|
|
249
316
|
return updateProfileRun(args.address, options);
|
|
250
317
|
},
|
|
251
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
|
+
});
|
|
252
359
|
// ── Labels sub-resource ──
|
|
253
360
|
exports.profilesLabels = incur_1.Cli.create('labels', {
|
|
254
361
|
description: 'Manage labels on a wallet profile',
|
|
@@ -267,10 +374,15 @@ function buildCreateLabelBody(options) {
|
|
|
267
374
|
}
|
|
268
375
|
if (options.tagId) {
|
|
269
376
|
const single = { tag_id: options.tagId };
|
|
270
|
-
if (options.value)
|
|
377
|
+
if (options.value !== undefined)
|
|
271
378
|
single.value = options.value;
|
|
272
379
|
if (options.chainId)
|
|
273
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
|
+
}
|
|
274
386
|
return single;
|
|
275
387
|
}
|
|
276
388
|
throw new Error('Provide --tag-id (single label) or --labels (batch JSON array)');
|
|
@@ -292,10 +404,18 @@ exports.profilesLabels.command('create', {
|
|
|
292
404
|
.describe('Label identifier (e.g. "vip", "airdrop_eligible")'),
|
|
293
405
|
value: incur_1.z.string().optional().describe('Optional label value (e.g. tier name, country code)'),
|
|
294
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'),
|
|
295
415
|
labels: incur_1.z
|
|
296
416
|
.string()
|
|
297
417
|
.optional()
|
|
298
|
-
.describe('JSON array of UserLabelInput objects for
|
|
418
|
+
.describe('JSON array of UserLabelInput objects for this wallet'),
|
|
299
419
|
}),
|
|
300
420
|
examples: [
|
|
301
421
|
{
|
|
@@ -308,6 +428,11 @@ exports.profilesLabels.command('create', {
|
|
|
308
428
|
options: { tagId: 'tier', value: 'gold', chainId: '1' },
|
|
309
429
|
description: 'Apply a tiered label scoped to a chain',
|
|
310
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
|
+
},
|
|
311
436
|
{
|
|
312
437
|
args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
|
|
313
438
|
options: { labels: '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]' },
|
|
@@ -319,6 +444,46 @@ exports.profilesLabels.command('create', {
|
|
|
319
444
|
return createProfileLabelRun(args.address, options);
|
|
320
445
|
},
|
|
321
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
|
+
});
|
|
322
487
|
function buildDeleteLabelBody(options) {
|
|
323
488
|
if (!options.tagId) {
|
|
324
489
|
throw new Error('--tag-id is required');
|
|
@@ -359,4 +524,5 @@ exports.profilesLabels.command('delete', {
|
|
|
359
524
|
return deleteProfileLabelRun(args.address, options);
|
|
360
525
|
},
|
|
361
526
|
});
|
|
527
|
+
exports.profiles.command(exports.profilesProperties);
|
|
362
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,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,15 +7,16 @@ 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
|
"",
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL helpers for the query command.
|
|
3
|
+
*
|
|
4
|
+
* The Formo query API wraps the SQL you submit so it can paginate the result
|
|
5
|
+
* set and force a machine-readable response:
|
|
6
|
+
*
|
|
7
|
+
* SELECT * FROM (<your query>) LIMIT <n> FORMAT JSON
|
|
8
|
+
*
|
|
9
|
+
* ClickHouse does not allow a `FORMAT` clause inside a subquery, so if your
|
|
10
|
+
* query ends in its own `FORMAT` clause the wrapped statement becomes:
|
|
11
|
+
*
|
|
12
|
+
* SELECT * FROM (SELECT ... FORMAT CSV) LIMIT 100 FORMAT JSON
|
|
13
|
+
*
|
|
14
|
+
* which ClickHouse rejects with a 400. A trailing `FORMAT` (or a trailing
|
|
15
|
+
* semicolon) can never take effect through this endpoint anyway — the outer
|
|
16
|
+
* `FORMAT JSON` always wins, and output shaping is the CLI's `--format` job —
|
|
17
|
+
* so we remove it before sending and let the server wrap a clean query.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Strip a trailing, top-level `FORMAT <name>` clause and any trailing
|
|
21
|
+
* semicolons from a SQL statement.
|
|
22
|
+
*
|
|
23
|
+
* The scan is aware of string literals, quoted identifiers, and comments, so
|
|
24
|
+
* `FORMAT`-looking text inside them is never mistaken for a real clause. The
|
|
25
|
+
* match is anchored to the end of the statement, so a `FORMAT` nested inside
|
|
26
|
+
* parentheses (a subquery) or part of an identifier/function such as
|
|
27
|
+
* `formatDateTime(...)` — or a column aliased `format` — is left untouched.
|
|
28
|
+
*
|
|
29
|
+
* Returns the original input unchanged when there is nothing to strip.
|
|
30
|
+
*/
|
|
31
|
+
export declare function stripTrailingFormatClause(sql: string): string;
|