@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.
@@ -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.parseSearchConditions = parseSearchConditions;
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
- // Accepted first segments for a FilterCondition `field`, mirroring the API's
115
- // parseField(). A field whose prefix is not one of these is silently ignored
116
- // server-side (no error, no filtering — the search returns everything), so we
117
- // reject it client-side with an actionable message instead.
118
- const CONDITION_FIELD_PREFIXES = new Set([
119
- 'user',
120
- 'users',
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
- * Parse and validate the --conditions JSON. Ensures it is an array of
132
- * `{ field, op, value }` objects whose `field` is a typed path (e.g.
133
- * `users.net_worth_usd`) — a bare name like `net_worth_usd` is silently
134
- * dropped by the API, so it is rejected here. Exported for unit testing.
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 parseSearchConditions(raw) {
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('--conditions must be a valid JSON array of FilterCondition objects');
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('--conditions must be a valid JSON array of FilterCondition objects');
222
+ throw new Error('--filters must be a valid JSON array of FilterCondition objects');
146
223
  }
147
- for (const cond of parsed) {
148
- if (!cond || typeof cond !== 'object' || Array.isArray(cond)) {
149
- throw new Error('--conditions: each entry must be an object with field, op, value');
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 field = cond.field;
228
+ const record = filter;
229
+ const field = record.field;
152
230
  if (typeof field !== 'string' || field.length === 0) {
153
- throw new Error('--conditions: each entry must have a non-empty string "field"');
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 (!field.includes('.') || !CONDITION_FIELD_PREFIXES.has(field.split('.')[0])) {
156
- throw new Error(`--conditions: field "${field}" must be a typed path — prefix it with ` +
157
- 'users., chains., apps., tokens., or labels. ' +
158
- '(a bare name is silently ignored by the API and returns the entire unfiltered dataset)');
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.conditions) {
297
+ if (options.filters) {
184
298
  body = {
185
- conditions: parseSearchConditions(options.conditions),
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
- // `{ conditions, logic }` filter object in the *request body* (see
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.number().optional().describe('Page number (1-indexed, default 1)'),
203
- size: incur_1.z.coerce.number().optional().describe('Page size (default 100, max 1000)'),
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
- conditions: incur_1.z
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
- 'Chains: chains.balance or chains.{chain_id}.balance. ' +
231
- 'Apps: apps.{app_id}.balance. Tokens: tokens.{address}.balance ' +
232
- '(optional "scope":"any"|"protocol" + "appId"). Labels: labels.{tag_id}. ' +
233
- 'op: eq, neq, gt, gte, lt, lte, in, nin, 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.'),
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 conditions: "and" (default) or "or"'),
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
- conditions: '[{"field":"users.net_worth_usd","op":"gt","value":10000}]',
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
- conditions: '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]',
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
- conditions: '[{"field":"chains.1.balance","op":"gt","value":1000}]',
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 typed path (e.g. users.net_worth_usd) — bare names are ignored by the API.',
276
- run({ args: _args, options }) {
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
- try {
368
- const parsed = JSON.parse(options.labels);
369
- if (!Array.isArray(parsed) || parsed.length === 0)
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
- catch {
374
- throw new Error('--labels must be a non-empty JSON array of UserLabelInput objects');
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 };
@@ -1,3 +1,3 @@
1
1
  import { Cli } from 'incur';
2
2
  export declare const query: Cli.Cli<{}, undefined, undefined>;
3
- export declare function queryRunRun(sql: string): Promise<import("axios").AxiosResponse<any, any, {}>>;
3
+ export declare function queryRunRun(sql: string): Promise<unknown>;
@@ -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 interface PaginationOptions {
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
- filterSets: string;
8
+ filters: string;
11
9
  }
12
10
  export declare function buildCreateSegmentBody(options: CreateSegmentOptions): {
13
11
  title: string;
14
- filterSets: any[];
12
+ filters: unknown[];
15
13
  };
16
- export declare function createSegmentRun(options: CreateSegmentOptions): Promise<import("axios").AxiosResponse<any, any, {}>>;
17
- export declare function deleteSegmentRun(segmentId: string): Promise<import("axios").AxiosResponse<any, any, {}>>;
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
- 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
- }
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
- let parsedFilterSets;
40
- try {
41
- parsedFilterSets = JSON.parse(options.filterSets);
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
- catch {
47
- throw new Error('--filter-sets must be a valid JSON array');
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
- filterSets: parsedFilterSets,
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
- filterSets: incur_1.z
83
+ filters: incur_1.z
64
84
  .string()
65
- .describe('JSON array of filter set strings defining the segment'),
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
- filterSets: '["net_worth_usd > 100000"]',
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 null;
57
+ return { kind: "invalid" };
49
58
  const body = (await res.json());
50
59
  if (!body.validated)
51
- return null;
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 null;
68
+ return { kind: "unreachable" };
59
69
  }
60
70
  }
61
71
  const cli = incur_1.Cli.create("formo", {
62
- version: "1.0.1",
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 workspaceInfo = await validateAndFetchWorkspace(args.apiKey);
110
- // Save key + workspace info (save even if validation fails — user might be offline)
111
- // Only include workspace/projectId when present so offline logins don't wipe existing values
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?.workspace !== undefined && {
115
- workspace: workspaceInfo.workspace,
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.length > 12
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("~/.config/formo/config.json")}`) +
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("~/.config/formo/config.json")}`) +
164
+ (0, ui_1.info)(`Config: ${ui_1.color.dim((0, config_1.getConfigFile)())}`) +
151
165
  "\n" +
152
- ui_1.color.dim("The API might be unreachable. Your key is saved and will be used for requests.") +
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)("API key removed from ~/.config/formo/config.json") +
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.length > 12
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 ? "~/.config/formo/config.json" : null,
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 || (args.length === 1 && args[0] === "--help");
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
  }