@formo/cli 1.1.0 → 1.2.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/README.md +92 -31
- package/dist/commands/alerts.d.ts +14 -22
- package/dist/commands/alerts.js +48 -49
- package/dist/commands/analytics.d.ts +1 -1
- package/dist/commands/analytics.js +51 -11
- package/dist/commands/boards.d.ts +7 -9
- package/dist/commands/boards.js +4 -14
- package/dist/commands/charts.d.ts +11 -13
- package/dist/commands/charts.js +12 -16
- package/dist/commands/contracts.d.ts +9 -11
- package/dist/commands/contracts.js +7 -17
- package/dist/commands/events.d.ts +1 -1
- package/dist/commands/events.js +6 -5
- package/dist/commands/import.d.ts +1 -1
- package/dist/commands/import.js +3 -0
- package/dist/commands/profiles.d.ts +13 -13
- package/dist/commands/profiles.js +189 -49
- package/dist/commands/query.d.ts +1 -1
- package/dist/commands/segments.d.ts +7 -9
- package/dist/commands/segments.js +45 -25
- package/dist/index.js +39 -26
- package/dist/lib/client.d.ts +18 -3
- package/dist/lib/client.js +11 -2
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.js +32 -18
- package/dist/lib/filters.d.ts +6 -0
- package/dist/lib/filters.js +45 -0
- package/dist/lib/pagination.d.ts +14 -0
- package/dist/lib/pagination.js +31 -0
- package/dist/lib/ui.d.ts +6 -5
- package/dist/lib/ui.js +10 -12
- package/package.json +8 -12
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.profilesLabels = exports.profilesProperties = exports.profiles = void 0;
|
|
4
4
|
exports.getProfileRun = getProfileRun;
|
|
5
|
-
exports.
|
|
5
|
+
exports.parseSearchFilters = parseSearchFilters;
|
|
6
6
|
exports.searchProfilesRun = searchProfilesRun;
|
|
7
7
|
exports.buildUpdateProfileBody = buildUpdateProfileBody;
|
|
8
8
|
exports.updateProfileRun = updateProfileRun;
|
|
@@ -16,6 +16,7 @@ exports.buildDeleteLabelBody = buildDeleteLabelBody;
|
|
|
16
16
|
exports.deleteProfileLabelRun = deleteProfileLabelRun;
|
|
17
17
|
const incur_1 = require("incur");
|
|
18
18
|
const client_1 = require("../lib/client");
|
|
19
|
+
const filters_1 = require("../lib/filters");
|
|
19
20
|
const json_1 = require("../lib/json");
|
|
20
21
|
exports.profiles = incur_1.Cli.create('profiles', {
|
|
21
22
|
description: 'Wallet profile commands',
|
|
@@ -111,13 +112,23 @@ exports.profiles.command('get', {
|
|
|
111
112
|
return getProfileRun(args.address, options);
|
|
112
113
|
},
|
|
113
114
|
});
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
// Prefixes that may lead a user-surface `field` (e.g. `users.net_worth_usd`).
|
|
116
|
+
// A bare name like `net_worth_usd` is silently ignored server-side (no error,
|
|
117
|
+
// no filtering — the search returns everything), so we reject it client-side.
|
|
118
|
+
const USER_FIELD_PREFIXES = new Set(['user', 'users']);
|
|
119
|
+
// The four canonical resource filter fields. Resource identity lives in named
|
|
120
|
+
// qualifier properties — never in the field path. The retired
|
|
121
|
+
// identifier-in-path spellings (`chains.1.balance`, `apps.uniswap-v3.balance`,
|
|
122
|
+
// `tokens.0x….balance`, `labels.vip`) are rejected by the API with a 400.
|
|
123
|
+
const RESOURCE_FILTER_FIELDS = new Set([
|
|
124
|
+
'chains.balance',
|
|
125
|
+
'apps.balance',
|
|
126
|
+
'tokens.balance',
|
|
127
|
+
'labels.value',
|
|
128
|
+
]);
|
|
129
|
+
// Prefixes owned by the resource fields above. A field that leads with one of
|
|
130
|
+
// these but is not an exact canonical path is a retired dynamic path.
|
|
131
|
+
const RESOURCE_FIELD_PREFIXES = new Set([
|
|
121
132
|
'chain',
|
|
122
133
|
'chains',
|
|
123
134
|
'app',
|
|
@@ -127,35 +138,138 @@ const CONDITION_FIELD_PREFIXES = new Set([
|
|
|
127
138
|
'label',
|
|
128
139
|
'labels',
|
|
129
140
|
]);
|
|
141
|
+
const QUALIFIER_KEYS = [
|
|
142
|
+
'chain_id',
|
|
143
|
+
'app_id',
|
|
144
|
+
'token_address',
|
|
145
|
+
'tag_id',
|
|
146
|
+
'scope',
|
|
147
|
+
];
|
|
148
|
+
const FILTER_ENTRY_KEYS = new Set([
|
|
149
|
+
'field',
|
|
150
|
+
'op',
|
|
151
|
+
'value',
|
|
152
|
+
...QUALIFIER_KEYS,
|
|
153
|
+
]);
|
|
130
154
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* dropped by the API, so it is rejected here. Exported for unit testing.
|
|
155
|
+
* Enforce the per-field qualifier rules, mirroring the API's schema. Sending a
|
|
156
|
+
* qualifier the field does not accept — or omitting a required one — is a 400,
|
|
157
|
+
* so we fail here with a message that names the offending key.
|
|
135
158
|
*/
|
|
136
|
-
function
|
|
159
|
+
function validateQualifiers(record, field) {
|
|
160
|
+
const present = (key) => record[key] !== undefined;
|
|
161
|
+
const required = (key) => {
|
|
162
|
+
if (!present(key)) {
|
|
163
|
+
throw new Error(`--filters: "${key}" is required for "${field}"`);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
const forbidden = (keys) => {
|
|
167
|
+
for (const key of keys) {
|
|
168
|
+
if (present(key)) {
|
|
169
|
+
throw new Error(`--filters: "${key}" is not valid for "${field}"`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
switch (field) {
|
|
174
|
+
case 'chains.balance':
|
|
175
|
+
// chain_id optional — omit it to match any chain.
|
|
176
|
+
forbidden(['app_id', 'token_address', 'tag_id', 'scope']);
|
|
177
|
+
break;
|
|
178
|
+
case 'apps.balance':
|
|
179
|
+
required('app_id');
|
|
180
|
+
forbidden(['token_address', 'tag_id', 'scope']);
|
|
181
|
+
break;
|
|
182
|
+
case 'tokens.balance':
|
|
183
|
+
required('token_address');
|
|
184
|
+
required('scope');
|
|
185
|
+
if (record.scope !== 'any' && record.scope !== 'protocol') {
|
|
186
|
+
throw new Error(`--filters: "scope" must be "any" or "protocol"`);
|
|
187
|
+
}
|
|
188
|
+
// app_id identifies the protocol, so it is required by (and only by)
|
|
189
|
+
// scope: "protocol".
|
|
190
|
+
if (record.scope === 'protocol') {
|
|
191
|
+
required('app_id');
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
forbidden(['app_id']);
|
|
195
|
+
}
|
|
196
|
+
forbidden(['tag_id']);
|
|
197
|
+
break;
|
|
198
|
+
case 'labels.value':
|
|
199
|
+
required('tag_id');
|
|
200
|
+
forbidden(['app_id', 'token_address', 'scope']);
|
|
201
|
+
break;
|
|
202
|
+
default:
|
|
203
|
+
// users.* — a user attribute carries no resource identity.
|
|
204
|
+
forbidden(QUALIFIER_KEYS);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Parse and validate the --filters JSON. Ensures it is an array of
|
|
209
|
+
* `{ field, op, value }` objects carrying a canonical `field` — either
|
|
210
|
+
* `users.{attribute}` or one of the four stable resource paths, with resource
|
|
211
|
+
* identity in named qualifier properties. Exported for unit testing.
|
|
212
|
+
*/
|
|
213
|
+
function parseSearchFilters(raw) {
|
|
137
214
|
let parsed;
|
|
138
215
|
try {
|
|
139
216
|
parsed = JSON.parse(raw);
|
|
140
217
|
}
|
|
141
218
|
catch {
|
|
142
|
-
throw new Error('--
|
|
219
|
+
throw new Error('--filters must be a valid JSON array of FilterCondition objects');
|
|
143
220
|
}
|
|
144
221
|
if (!Array.isArray(parsed)) {
|
|
145
|
-
throw new Error('--
|
|
222
|
+
throw new Error('--filters must be a valid JSON array of FilterCondition objects');
|
|
146
223
|
}
|
|
147
|
-
for (const
|
|
148
|
-
if (!
|
|
149
|
-
throw new Error('--
|
|
224
|
+
for (const filter of parsed) {
|
|
225
|
+
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
|
|
226
|
+
throw new Error('--filters: each entry must be an object with field, op, value');
|
|
150
227
|
}
|
|
151
|
-
const
|
|
228
|
+
const record = filter;
|
|
229
|
+
const field = record.field;
|
|
152
230
|
if (typeof field !== 'string' || field.length === 0) {
|
|
153
|
-
throw new Error('--
|
|
231
|
+
throw new Error('--filters: each entry must have a non-empty string "field"');
|
|
232
|
+
}
|
|
233
|
+
for (const key of Object.keys(record)) {
|
|
234
|
+
if (!FILTER_ENTRY_KEYS.has(key)) {
|
|
235
|
+
// `appId` was the pre-P-2387 spelling; the API now rejects unknown keys.
|
|
236
|
+
const hint = key === 'appId' ? ' — use the snake_case "app_id" qualifier' : '';
|
|
237
|
+
throw new Error(`--filters: unknown property "${key}"${hint}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const prefix = field.split('.')[0];
|
|
241
|
+
if (!RESOURCE_FILTER_FIELDS.has(field)) {
|
|
242
|
+
if (RESOURCE_FIELD_PREFIXES.has(prefix)) {
|
|
243
|
+
throw new Error(`--filters: field "${field}" is a retired identifier-in-path spelling. ` +
|
|
244
|
+
'Use a stable path — chains.balance, apps.balance, tokens.balance, or labels.value — ' +
|
|
245
|
+
'and move the identifier into a qualifier (chain_id, app_id, token_address, tag_id). ' +
|
|
246
|
+
'The API rejects the old form with a 400.');
|
|
247
|
+
}
|
|
248
|
+
if (!field.includes('.') || !USER_FIELD_PREFIXES.has(prefix)) {
|
|
249
|
+
throw new Error(`--filters: field "${field}" must be a canonical path — either ` +
|
|
250
|
+
'users.{attribute}, or one of chains.balance, apps.balance, tokens.balance, labels.value ' +
|
|
251
|
+
'(a bare name is silently ignored by the API and returns the entire unfiltered dataset)');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
validateQualifiers(record, field);
|
|
255
|
+
// The balance fields compare numerically; a stringified number is a 400.
|
|
256
|
+
if (field !== 'labels.value' &&
|
|
257
|
+
RESOURCE_FILTER_FIELDS.has(field) &&
|
|
258
|
+
typeof record.value !== 'number') {
|
|
259
|
+
throw new Error(`--filters: "value" must be a number for "${field}"`);
|
|
154
260
|
}
|
|
155
|
-
if (
|
|
156
|
-
throw new Error(`--
|
|
157
|
-
|
|
158
|
-
|
|
261
|
+
if (field === 'labels.value' && record.value === '') {
|
|
262
|
+
throw new Error(`--filters: "value" must be non-empty for "labels.value"`);
|
|
263
|
+
}
|
|
264
|
+
if (!(0, filters_1.isCanonicalFilterOperator)(record.op)) {
|
|
265
|
+
throw new Error('--filters: each entry must use a canonical "op" (eq, neq, gt, lt, gte, lte, in, nin, startsWith, endsWith, contains, notEmpty, or isEmpty)');
|
|
266
|
+
}
|
|
267
|
+
if ((0, filters_1.isEmptyMembershipArray)(record.value)) {
|
|
268
|
+
throw new Error('--filters: membership arrays cannot be empty');
|
|
269
|
+
}
|
|
270
|
+
if (!(0, filters_1.isValuelessFilterOperator)(record.op) &&
|
|
271
|
+
(record.value === undefined || record.value === null)) {
|
|
272
|
+
throw new Error('--filters: "value" is required for every operator except notEmpty/isEmpty');
|
|
159
273
|
}
|
|
160
274
|
}
|
|
161
275
|
return parsed;
|
|
@@ -180,14 +294,14 @@ function searchProfilesRun(options) {
|
|
|
180
294
|
params.expand = options.expand;
|
|
181
295
|
addLifecycleThresholdParams(params, options);
|
|
182
296
|
let body;
|
|
183
|
-
if (options.
|
|
297
|
+
if (options.filters) {
|
|
184
298
|
body = {
|
|
185
|
-
|
|
299
|
+
filters: parseSearchFilters(options.filters),
|
|
186
300
|
logic: options.logic ?? 'and',
|
|
187
301
|
};
|
|
188
302
|
}
|
|
189
303
|
// INTENTIONAL: the Formo search API is `GET /v0/profiles` with the
|
|
190
|
-
// `{
|
|
304
|
+
// `{ filters, logic }` filter object in the *request body* (see
|
|
191
305
|
// docs.formo.so/api/profiles/search — it has a "Request Body (Filters)"
|
|
192
306
|
// section under a GET endpoint). This GET-with-body shape is the
|
|
193
307
|
// documented, server-supported contract. Do NOT "fix" it to POST — that
|
|
@@ -199,8 +313,18 @@ exports.profiles.command('search', {
|
|
|
199
313
|
options: incur_1.z.object({
|
|
200
314
|
address: incur_1.z.string().optional().describe('Filter by wallet address'),
|
|
201
315
|
search: incur_1.z.string().optional().describe('Free-text search across address and identity fields'),
|
|
202
|
-
page: incur_1.z.coerce
|
|
203
|
-
|
|
316
|
+
page: incur_1.z.coerce
|
|
317
|
+
.number()
|
|
318
|
+
.int()
|
|
319
|
+
.positive()
|
|
320
|
+
.optional()
|
|
321
|
+
.describe('Page number (1-indexed, default 1)'),
|
|
322
|
+
size: incur_1.z.coerce
|
|
323
|
+
.number()
|
|
324
|
+
.int()
|
|
325
|
+
.positive()
|
|
326
|
+
.optional()
|
|
327
|
+
.describe('Page size (default 100, max 1000)'),
|
|
204
328
|
orderBy: incur_1.z
|
|
205
329
|
.enum([
|
|
206
330
|
'last_onchain',
|
|
@@ -219,7 +343,7 @@ exports.profiles.command('search', {
|
|
|
219
343
|
.describe('Field to sort by'),
|
|
220
344
|
orderDir: incur_1.z.enum(['asc', 'desc']).optional().describe('Sort direction'),
|
|
221
345
|
expand: incur_1.z.string().optional().describe('Comma-separated fields to expand'),
|
|
222
|
-
|
|
346
|
+
filters: incur_1.z
|
|
223
347
|
.string()
|
|
224
348
|
.optional()
|
|
225
349
|
.describe('JSON array of FilterCondition objects: [{"field","op","value"}]. ' +
|
|
@@ -227,16 +351,24 @@ exports.profiles.command('search', {
|
|
|
227
351
|
'Profile: users.net_worth_usd, users.volume, users.revenue, users.points. ' +
|
|
228
352
|
'Engagement: users.device, users.browser, users.os, users.location, users.lifecycle. ' +
|
|
229
353
|
'Socials: users.ens, users.farcaster, users.lens, etc. ' +
|
|
230
|
-
'
|
|
231
|
-
'
|
|
232
|
-
'(
|
|
233
|
-
'
|
|
234
|
-
'(
|
|
235
|
-
'
|
|
354
|
+
'Resource filters use a stable field plus named qualifiers: ' +
|
|
355
|
+
'chains.balance (+ optional "chain_id"); ' +
|
|
356
|
+
'apps.balance (+ "app_id", optional "chain_id"); ' +
|
|
357
|
+
'tokens.balance (+ "token_address", "scope":"any"|"protocol", "app_id" when scope is "protocol", optional "chain_id"); ' +
|
|
358
|
+
'labels.value (+ "tag_id", optional "chain_id"). ' +
|
|
359
|
+
'op: eq, neq, gt, gte, lt, lte, in, nin, contains, startsWith, endsWith, notEmpty, isEmpty. ' +
|
|
360
|
+
'Operator support is per field: the .balance fields take comparison operators only; ' +
|
|
361
|
+
'contains works on routable string attributes (users.device, users.os, users.referrer, users.utm_*, users.click_id — case-sensitive), ' +
|
|
362
|
+
'on social fields and on labels.value (case-insensitive); ' +
|
|
363
|
+
'startsWith/endsWith are routable string attributes only; ' +
|
|
364
|
+
'notEmpty/isEmpty are value-less existence checks on string fields; ' +
|
|
365
|
+
'users.lifecycle takes only eq and in. ' +
|
|
366
|
+
'Retired and rejected with a 400: identifier-in-path fields (chains.1.balance, apps.uniswap-v3.balance, tokens.0x….balance, labels.vip), ' +
|
|
367
|
+
'the "appId" spelling, and long-form operators (equals, notEquals, greater, greaterOrEqual, less, lessOrEqual, notIn, includes).'),
|
|
236
368
|
logic: incur_1.z
|
|
237
369
|
.enum(['and', 'or'])
|
|
238
370
|
.optional()
|
|
239
|
-
.describe('Logic operator for combining
|
|
371
|
+
.describe('Logic operator for combining filters: "and" (default) or "or"'),
|
|
240
372
|
...lifecycleThresholdOptions,
|
|
241
373
|
}),
|
|
242
374
|
examples: [
|
|
@@ -251,14 +383,14 @@ exports.profiles.command('search', {
|
|
|
251
383
|
},
|
|
252
384
|
{
|
|
253
385
|
options: {
|
|
254
|
-
|
|
386
|
+
filters: '[{"field":"users.net_worth_usd","op":"gt","value":10000}]',
|
|
255
387
|
size: 20,
|
|
256
388
|
},
|
|
257
389
|
description: 'Search profiles with net worth > $10k',
|
|
258
390
|
},
|
|
259
391
|
{
|
|
260
392
|
options: {
|
|
261
|
-
|
|
393
|
+
filters: '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]',
|
|
262
394
|
logic: 'or',
|
|
263
395
|
size: 20,
|
|
264
396
|
},
|
|
@@ -266,14 +398,21 @@ exports.profiles.command('search', {
|
|
|
266
398
|
},
|
|
267
399
|
{
|
|
268
400
|
options: {
|
|
269
|
-
|
|
401
|
+
filters: '[{"field":"chains.balance","op":"gt","value":1000,"chain_id":"1"}]',
|
|
270
402
|
size: 20,
|
|
271
403
|
},
|
|
272
404
|
description: 'Search profiles with > $1k balance on Ethereum (chain 1)',
|
|
273
405
|
},
|
|
406
|
+
{
|
|
407
|
+
options: {
|
|
408
|
+
filters: '[{"field":"labels.value","op":"eq","value":"tier-1","tag_id":"vip"}]',
|
|
409
|
+
size: 20,
|
|
410
|
+
},
|
|
411
|
+
description: 'Search profiles carrying the vip label with value tier-1',
|
|
412
|
+
},
|
|
274
413
|
],
|
|
275
|
-
hint: 'Requires profiles:read scope on your API key. Filter "field" must be a
|
|
276
|
-
run({
|
|
414
|
+
hint: 'Requires profiles:read scope on your API key. Filter "field" must be a canonical path (users.{attribute}, chains.balance, apps.balance, tokens.balance, labels.value) with resource identity in the chain_id/app_id/token_address/tag_id qualifiers — bare names are ignored by the API and identifier-in-path fields are rejected with a 400.',
|
|
415
|
+
run({ options }) {
|
|
277
416
|
return searchProfilesRun(options);
|
|
278
417
|
},
|
|
279
418
|
});
|
|
@@ -364,15 +503,16 @@ exports.profilesLabels = incur_1.Cli.create('labels', {
|
|
|
364
503
|
});
|
|
365
504
|
function buildCreateLabelBody(options) {
|
|
366
505
|
if (options.labels) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
throw new Error('not a non-empty array');
|
|
371
|
-
return parsed;
|
|
506
|
+
const parsed = (0, json_1.parseJsonArrayOfObjects)(options.labels, '--labels');
|
|
507
|
+
if (parsed.length === 0) {
|
|
508
|
+
throw new Error('--labels must contain at least one item');
|
|
372
509
|
}
|
|
373
|
-
|
|
374
|
-
|
|
510
|
+
for (const label of parsed) {
|
|
511
|
+
if (typeof label.tag_id !== 'string' || label.tag_id.length === 0) {
|
|
512
|
+
throw new Error('--labels entries must each include a non-empty string tag_id');
|
|
513
|
+
}
|
|
375
514
|
}
|
|
515
|
+
return parsed;
|
|
376
516
|
}
|
|
377
517
|
if (options.tagId) {
|
|
378
518
|
const single = { tag_id: options.tagId };
|
package/dist/commands/query.d.ts
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
import { Cli } from 'incur';
|
|
2
|
+
import { type PaginationOptions } from '../lib/pagination';
|
|
3
|
+
export type { PaginationOptions };
|
|
2
4
|
export declare const segments: Cli.Cli<{}, undefined, undefined>;
|
|
3
|
-
export
|
|
4
|
-
page?: number;
|
|
5
|
-
size?: number;
|
|
6
|
-
}
|
|
7
|
-
export declare function listSegmentsRun(options?: PaginationOptions): Promise<import("axios").AxiosResponse<any, any, {}>>;
|
|
5
|
+
export declare function listSegmentsRun(options?: PaginationOptions): Promise<unknown>;
|
|
8
6
|
export interface CreateSegmentOptions {
|
|
9
7
|
title: string;
|
|
10
|
-
|
|
8
|
+
filters: string;
|
|
11
9
|
}
|
|
12
10
|
export declare function buildCreateSegmentBody(options: CreateSegmentOptions): {
|
|
13
11
|
title: string;
|
|
14
|
-
|
|
12
|
+
filters: unknown[];
|
|
15
13
|
};
|
|
16
|
-
export declare function createSegmentRun(options: CreateSegmentOptions): Promise<
|
|
17
|
-
export declare function deleteSegmentRun(segmentId: string): Promise<
|
|
14
|
+
export declare function createSegmentRun(options: CreateSegmentOptions): Promise<unknown>;
|
|
15
|
+
export declare function deleteSegmentRun(segmentId: string): Promise<unknown>;
|
|
@@ -7,48 +7,68 @@ exports.createSegmentRun = createSegmentRun;
|
|
|
7
7
|
exports.deleteSegmentRun = deleteSegmentRun;
|
|
8
8
|
const incur_1 = require("incur");
|
|
9
9
|
const client_1 = require("../lib/client");
|
|
10
|
+
const filters_1 = require("../lib/filters");
|
|
11
|
+
const json_1 = require("../lib/json");
|
|
12
|
+
const pagination_1 = require("../lib/pagination");
|
|
10
13
|
exports.segments = incur_1.Cli.create('segments', {
|
|
11
14
|
description: 'User segment commands — create, list, and delete audience segments',
|
|
12
15
|
});
|
|
13
|
-
|
|
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
|
-
}
|
|
16
|
+
// ── List segments ──
|
|
21
17
|
function listSegmentsRun(options = {}) {
|
|
22
18
|
(0, client_1.requireApiKey)();
|
|
23
19
|
const client = (0, client_1.createClient)();
|
|
24
|
-
return client.get('/v0/segments/', { params: buildPaginationParams(options) });
|
|
20
|
+
return client.get('/v0/segments/', { params: (0, pagination_1.buildPaginationParams)(options) });
|
|
25
21
|
}
|
|
26
22
|
exports.segments.command('list', {
|
|
27
23
|
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
|
-
}),
|
|
24
|
+
options: incur_1.z.object(pagination_1.paginationOptionsSchema),
|
|
32
25
|
examples: [{ description: 'List all project segments' }],
|
|
33
26
|
hint: 'Requires segments:read scope on your API key.',
|
|
34
27
|
run({ options }) {
|
|
35
28
|
return listSegmentsRun(options);
|
|
36
29
|
},
|
|
37
30
|
});
|
|
31
|
+
const SEGMENT_FILTER_KEYS = new Set(['field', 'op', 'value']);
|
|
38
32
|
function buildCreateSegmentBody(options) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (!Array.isArray(parsedFilterSets)) {
|
|
43
|
-
throw new Error('not an array');
|
|
44
|
-
}
|
|
33
|
+
const filters = (0, json_1.parseJsonArray)(options.filters, '--filters');
|
|
34
|
+
if (filters.length === 0) {
|
|
35
|
+
throw new Error('--filters must contain at least one filter');
|
|
45
36
|
}
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
for (const filter of filters) {
|
|
38
|
+
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
|
|
39
|
+
throw new Error('--filters must be a JSON array of {field, op, value} objects');
|
|
40
|
+
}
|
|
41
|
+
const record = filter;
|
|
42
|
+
if (Object.keys(record).some((key) => !SEGMENT_FILTER_KEYS.has(key))) {
|
|
43
|
+
throw new Error('--filters entries may only contain field, op, and value');
|
|
44
|
+
}
|
|
45
|
+
if (typeof record.field !== 'string' || record.field.length === 0) {
|
|
46
|
+
throw new Error('--filters: each entry requires a non-empty string "field"');
|
|
47
|
+
}
|
|
48
|
+
if (!(0, filters_1.isCanonicalFilterOperator)(record.op)) {
|
|
49
|
+
throw new Error('--filters: each entry requires a canonical "op"');
|
|
50
|
+
}
|
|
51
|
+
if ((0, filters_1.isEmptyMembershipArray)(record.value)) {
|
|
52
|
+
throw new Error('--filters: membership arrays cannot be empty');
|
|
53
|
+
}
|
|
54
|
+
if (!(0, filters_1.isValuelessFilterOperator)(record.op) &&
|
|
55
|
+
(record.value === undefined ||
|
|
56
|
+
record.value === null ||
|
|
57
|
+
!(0, filters_1.isCanonicalFilterValue)(record.value))) {
|
|
58
|
+
throw new Error('--filters: "value" is required for every operator except notEmpty/isEmpty');
|
|
59
|
+
}
|
|
60
|
+
if (record.value !== undefined &&
|
|
61
|
+
record.value !== null &&
|
|
62
|
+
!(0, filters_1.isCanonicalFilterValue)(record.value)) {
|
|
63
|
+
throw new Error('--filters: "value" must be a string, number, boolean, or string/number array');
|
|
64
|
+
}
|
|
65
|
+
if ((0, filters_1.hasTinybirdMembershipDelimiter)(record.value)) {
|
|
66
|
+
throw new Error('--filters: array string members cannot contain "|" because it is the Tinybird membership separator');
|
|
67
|
+
}
|
|
48
68
|
}
|
|
49
69
|
return {
|
|
50
70
|
title: options.title,
|
|
51
|
-
|
|
71
|
+
filters,
|
|
52
72
|
};
|
|
53
73
|
}
|
|
54
74
|
function createSegmentRun(options) {
|
|
@@ -60,15 +80,15 @@ exports.segments.command('create', {
|
|
|
60
80
|
description: 'Create a new user segment',
|
|
61
81
|
options: incur_1.z.object({
|
|
62
82
|
title: incur_1.z.string().describe('Segment title'),
|
|
63
|
-
|
|
83
|
+
filters: incur_1.z
|
|
64
84
|
.string()
|
|
65
|
-
.describe('JSON array of filter
|
|
85
|
+
.describe('JSON array of canonical filter objects: [{"field","op","value"}]. Array string members cannot contain "|".'),
|
|
66
86
|
}),
|
|
67
87
|
examples: [
|
|
68
88
|
{
|
|
69
89
|
options: {
|
|
70
90
|
title: 'Whales',
|
|
71
|
-
|
|
91
|
+
filters: '[{"field":"net_worth_usd","op":"gt","value":"100000"}]',
|
|
72
92
|
},
|
|
73
93
|
description: 'Create a high-value segment',
|
|
74
94
|
},
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,11 @@ const segments_1 = require("./commands/segments");
|
|
|
15
15
|
const client_1 = require("./lib/client");
|
|
16
16
|
const config_1 = require("./lib/config");
|
|
17
17
|
const ui_1 = require("./lib/ui");
|
|
18
|
+
// Single source of truth for --version; a hardcoded literal here drifted
|
|
19
|
+
// from package.json more than once. package.json sits outside rootDir, so
|
|
20
|
+
// use require() rather than an import (tsc would widen the output layout).
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
22
|
+
const { version: VERSION } = require("../package.json");
|
|
18
23
|
const DASHBOARD_URL = "https://app.formo.so";
|
|
19
24
|
const DOCS_URL = "https://docs.formo.so";
|
|
20
25
|
function loginGuide() {
|
|
@@ -43,23 +48,28 @@ async function validateAndFetchWorkspace(apiKey) {
|
|
|
43
48
|
method: "POST",
|
|
44
49
|
headers: { "Content-Type": "application/json" },
|
|
45
50
|
body: JSON.stringify({ apiKey }),
|
|
51
|
+
// fetch has no default timeout — without this, login can hang forever.
|
|
52
|
+
signal: AbortSignal.timeout(10000),
|
|
46
53
|
});
|
|
54
|
+
// The server answered and rejected the key — distinct from "couldn't
|
|
55
|
+
// reach the server", so login can refuse to save a known-bad key.
|
|
47
56
|
if (!res.ok)
|
|
48
|
-
return
|
|
57
|
+
return { kind: "invalid" };
|
|
49
58
|
const body = (await res.json());
|
|
50
59
|
if (!body.validated)
|
|
51
|
-
return
|
|
60
|
+
return { kind: "invalid" };
|
|
52
61
|
return {
|
|
62
|
+
kind: "valid",
|
|
53
63
|
workspace: body.details,
|
|
54
64
|
projectId: body.scopes?.project_id ?? "",
|
|
55
65
|
};
|
|
56
66
|
}
|
|
57
67
|
catch {
|
|
58
|
-
return
|
|
68
|
+
return { kind: "unreachable" };
|
|
59
69
|
}
|
|
60
70
|
}
|
|
61
71
|
const cli = incur_1.Cli.create("formo", {
|
|
62
|
-
version:
|
|
72
|
+
version: VERSION,
|
|
63
73
|
description: "Formo API CLI — Web3 analytics from the terminal",
|
|
64
74
|
sync: {
|
|
65
75
|
suggestions: [
|
|
@@ -106,23 +116,27 @@ cli.command("login", {
|
|
|
106
116
|
if (isTTY) {
|
|
107
117
|
process.stderr.write("\n" + ui_1.color.dim("Validating API key…") + "\n");
|
|
108
118
|
}
|
|
109
|
-
const
|
|
110
|
-
//
|
|
111
|
-
//
|
|
119
|
+
const result = await validateAndFetchWorkspace(args.apiKey);
|
|
120
|
+
// The server explicitly rejected the key — refuse to save it. Saving a
|
|
121
|
+
// known-bad key just defers the failure to the user's next command.
|
|
122
|
+
if (result.kind === "invalid") {
|
|
123
|
+
throw new Error("API key was rejected by the Formo API (invalid or revoked). " +
|
|
124
|
+
"Check the key and try again, or create a new one at " +
|
|
125
|
+
`${DASHBOARD_URL} under Settings → API.`);
|
|
126
|
+
}
|
|
127
|
+
// Save the key. Offline (unreachable) logins still save — the user may
|
|
128
|
+
// simply have no network right now. Only include workspace/projectId
|
|
129
|
+
// when validated so offline logins don't wipe existing values, and only
|
|
130
|
+
// a non-empty projectId so an unscoped key doesn't clear a stored one.
|
|
131
|
+
const workspaceInfo = result.kind === "valid" ? result : undefined;
|
|
112
132
|
(0, config_1.saveConfig)({
|
|
113
133
|
apiKey: args.apiKey,
|
|
114
|
-
...(workspaceInfo
|
|
115
|
-
|
|
116
|
-
}),
|
|
117
|
-
...(workspaceInfo?.projectId !== undefined && {
|
|
118
|
-
projectId: workspaceInfo.projectId,
|
|
119
|
-
}),
|
|
134
|
+
...(workspaceInfo && { workspace: workspaceInfo.workspace }),
|
|
135
|
+
...(workspaceInfo?.projectId && { projectId: workspaceInfo.projectId }),
|
|
120
136
|
});
|
|
121
137
|
// Show feedback in human mode
|
|
122
138
|
if (isTTY) {
|
|
123
|
-
const masked = args.apiKey
|
|
124
|
-
? args.apiKey.slice(0, 8) + "…" + args.apiKey.slice(-4)
|
|
125
|
-
: "***";
|
|
139
|
+
const masked = (0, ui_1.maskKey)(args.apiKey);
|
|
126
140
|
if (workspaceInfo) {
|
|
127
141
|
process.stderr.write((0, ui_1.success)("API key validated and saved!") +
|
|
128
142
|
"\n" +
|
|
@@ -133,7 +147,7 @@ cli.command("login", {
|
|
|
133
147
|
(workspaceInfo.projectId
|
|
134
148
|
? (0, ui_1.info)(`Project: ${ui_1.color.dim(workspaceInfo.projectId)}`) + "\n"
|
|
135
149
|
: "") +
|
|
136
|
-
(0, ui_1.info)(`Config: ${ui_1.color.dim(
|
|
150
|
+
(0, ui_1.info)(`Config: ${ui_1.color.dim((0, config_1.getConfigFile)())}`) +
|
|
137
151
|
"\n\n" +
|
|
138
152
|
ui_1.color.dim("You can now use all formo commands.") +
|
|
139
153
|
"\n" +
|
|
@@ -147,9 +161,9 @@ cli.command("login", {
|
|
|
147
161
|
"\n" +
|
|
148
162
|
(0, ui_1.info)(`Key: ${ui_1.color.dim(masked)}`) +
|
|
149
163
|
"\n" +
|
|
150
|
-
(0, ui_1.info)(`Config: ${ui_1.color.dim(
|
|
164
|
+
(0, ui_1.info)(`Config: ${ui_1.color.dim((0, config_1.getConfigFile)())}`) +
|
|
151
165
|
"\n" +
|
|
152
|
-
ui_1.color.dim("The API
|
|
166
|
+
ui_1.color.dim("The API was unreachable. Your key is saved and will be used for requests.") +
|
|
153
167
|
"\n\n");
|
|
154
168
|
}
|
|
155
169
|
}
|
|
@@ -157,7 +171,7 @@ cli.command("login", {
|
|
|
157
171
|
ok: true,
|
|
158
172
|
message: workspaceInfo
|
|
159
173
|
? "API key validated and saved"
|
|
160
|
-
: "API key saved (not validated)",
|
|
174
|
+
: "API key saved (API unreachable, not validated)",
|
|
161
175
|
workspace: workspaceInfo?.workspace ?? null,
|
|
162
176
|
projectId: workspaceInfo?.projectId ?? null,
|
|
163
177
|
};
|
|
@@ -174,7 +188,7 @@ cli.command("logout", {
|
|
|
174
188
|
if (hadKey) {
|
|
175
189
|
process.stderr.write((0, ui_1.success)("Logged out successfully.") +
|
|
176
190
|
"\n" +
|
|
177
|
-
(0, ui_1.info)(
|
|
191
|
+
(0, ui_1.info)(`API key removed from ${(0, config_1.getConfigFile)()}`) +
|
|
178
192
|
"\n");
|
|
179
193
|
}
|
|
180
194
|
else {
|
|
@@ -208,9 +222,7 @@ cli.command("status", {
|
|
|
208
222
|
if (process.stdout.isTTY) {
|
|
209
223
|
process.stderr.write("\n");
|
|
210
224
|
if (apiKey) {
|
|
211
|
-
const masked = apiKey
|
|
212
|
-
? apiKey.slice(0, 8) + "…" + apiKey.slice(-4)
|
|
213
|
-
: "***";
|
|
225
|
+
const masked = (0, ui_1.maskKey)(apiKey);
|
|
214
226
|
process.stderr.write((0, ui_1.success)("Authenticated") +
|
|
215
227
|
"\n" +
|
|
216
228
|
(0, ui_1.info)(`Key: ${ui_1.color.dim(masked)}`) +
|
|
@@ -237,7 +249,7 @@ cli.command("status", {
|
|
|
237
249
|
source: apiKey ? source : null,
|
|
238
250
|
workspace: config.workspace ?? null,
|
|
239
251
|
projectId: config.projectId ?? null,
|
|
240
|
-
configFile: config.apiKey ?
|
|
252
|
+
configFile: config.apiKey ? (0, config_1.getConfigFile)() : null,
|
|
241
253
|
};
|
|
242
254
|
},
|
|
243
255
|
});
|
|
@@ -254,7 +266,8 @@ cli.command(segments_1.segments);
|
|
|
254
266
|
cli.command(import_1.importCmd);
|
|
255
267
|
// Show banner when run with no args (root help)
|
|
256
268
|
const args = process.argv.slice(2);
|
|
257
|
-
const isRootHelp = args.length === 0 ||
|
|
269
|
+
const isRootHelp = args.length === 0 ||
|
|
270
|
+
(args.length === 1 && (args[0] === "--help" || args[0] === "-h"));
|
|
258
271
|
if (isRootHelp && process.stdout.isTTY) {
|
|
259
272
|
process.stderr.write((0, ui_1.banner)());
|
|
260
273
|
}
|