@formo/cli 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 getProfileRun(address, expand) {
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.expand);
111
+ return getProfileRun(args.address, options);
48
112
  },
49
113
  });
50
114
  // Accepted first segments for a FilterCondition `field`, mirroring the API's
@@ -102,6 +166,8 @@ function searchProfilesRun(options) {
102
166
  const params = {};
103
167
  if (options.address)
104
168
  params.address = options.address;
169
+ if (options.search)
170
+ params.search = options.search;
105
171
  if (options.page !== undefined)
106
172
  params.page = options.page;
107
173
  if (options.size !== undefined)
@@ -112,6 +178,7 @@ function searchProfilesRun(options) {
112
178
  params.order_dir = options.orderDir;
113
179
  if (options.expand)
114
180
  params.expand = options.expand;
181
+ addLifecycleThresholdParams(params, options);
115
182
  let body;
116
183
  if (options.conditions) {
117
184
  body = {
@@ -119,12 +186,19 @@ function searchProfilesRun(options) {
119
186
  logic: options.logic ?? 'and',
120
187
  };
121
188
  }
189
+ // INTENTIONAL: the Formo search API is `GET /v0/profiles` with the
190
+ // `{ conditions, logic }` filter object in the *request body* (see
191
+ // docs.formo.so/api/profiles/search — it has a "Request Body (Filters)"
192
+ // section under a GET endpoint). This GET-with-body shape is the
193
+ // documented, server-supported contract. Do NOT "fix" it to POST — that
194
+ // breaks the API. Filter-less searches still go over query params only.
122
195
  return client.request({ method: 'get', url: '/v0/profiles/', params, data: body });
123
196
  }
124
197
  exports.profiles.command('search', {
125
198
  description: 'Search wallet profiles with optional filters',
126
199
  options: incur_1.z.object({
127
200
  address: incur_1.z.string().optional().describe('Filter by wallet address'),
201
+ search: incur_1.z.string().optional().describe('Free-text search across address and identity fields'),
128
202
  page: incur_1.z.coerce.number().optional().describe('Page number (1-indexed, default 1)'),
129
203
  size: incur_1.z.coerce.number().optional().describe('Page size (default 100, max 1000)'),
130
204
  orderBy: incur_1.z
@@ -156,11 +230,14 @@ exports.profiles.command('search', {
156
230
  'Chains: chains.balance or chains.{chain_id}.balance. ' +
157
231
  'Apps: apps.{app_id}.balance. Tokens: tokens.{address}.balance ' +
158
232
  '(optional "scope":"any"|"protocol" + "appId"). Labels: labels.{tag_id}. ' +
159
- 'op: eq, neq, gt, gte, lt, lte, in, nin.'),
233
+ 'op: eq, neq, gt, gte, lt, lte, in, nin, contains, notEmpty, isEmpty ' +
234
+ '(contains = substring, social fields only; notEmpty/isEmpty = value-less existence checks on string fields). ' +
235
+ 'Long-form spellings (equals, notEquals, greater, greaterOrEqual, less, lessOrEqual, notIn, includes) are retired; the API rejects them with a 400 naming the token.'),
160
236
  logic: incur_1.z
161
237
  .enum(['and', 'or'])
162
238
  .optional()
163
239
  .describe('Logic operator for combining conditions: "and" (default) or "or"'),
240
+ ...lifecycleThresholdOptions,
164
241
  }),
165
242
  examples: [
166
243
  { options: { size: 10 }, description: 'List first 10 profiles' },
@@ -201,15 +278,7 @@ exports.profiles.command('search', {
201
278
  },
202
279
  });
203
280
  function buildUpdateProfileBody(options) {
204
- let body;
205
- try {
206
- body = JSON.parse(options.properties);
207
- if (!body || typeof body !== 'object' || Array.isArray(body))
208
- throw new Error('not an object');
209
- }
210
- catch {
211
- throw new Error('--properties must be a JSON object of property keys');
212
- }
281
+ const body = (0, json_1.parseJsonObject)(options.properties, '--properties');
213
282
  if (Object.keys(body).length === 0) {
214
283
  throw new Error('--properties must contain at least one key');
215
284
  }
@@ -249,6 +318,46 @@ exports.profiles.command('update', {
249
318
  return updateProfileRun(args.address, options);
250
319
  },
251
320
  });
321
+ function buildBatchUpdateProfilesBody(options) {
322
+ const rows = (0, json_1.parseJsonArrayOfObjects)(options.rows, '--rows');
323
+ if (rows.length === 0) {
324
+ throw new Error('--rows must contain at least one item');
325
+ }
326
+ for (const row of rows) {
327
+ if (typeof row.address !== 'string' || row.address.length === 0) {
328
+ throw new Error('--rows entries must each include a non-empty string address');
329
+ }
330
+ }
331
+ return rows;
332
+ }
333
+ function batchUpdateProfilesRun(options) {
334
+ (0, client_1.requireApiKey)();
335
+ const client = (0, client_1.createClient)();
336
+ return client.post('/v0/profiles/properties', buildBatchUpdateProfilesBody(options));
337
+ }
338
+ exports.profilesProperties = incur_1.Cli.create('properties', {
339
+ description: 'Manage first-party profile properties in bulk',
340
+ });
341
+ exports.profilesProperties.command('batch', {
342
+ description: 'Batch update first-party profile properties for up to 100 wallets',
343
+ options: incur_1.z.object({
344
+ rows: incur_1.z
345
+ .string()
346
+ .describe('JSON array of flat {address,...properties} objects. ENS names are not resolved in batch requests.'),
347
+ }),
348
+ examples: [
349
+ {
350
+ options: {
351
+ rows: '[{"address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","display_name":"alice.eth","email":"alice@example.com"}]',
352
+ },
353
+ description: 'Batch set display names and emails',
354
+ },
355
+ ],
356
+ hint: 'Requires profiles:write scope on your API key. Unknown keys are ignored by the API; invalid rows are quarantined.',
357
+ run({ options }) {
358
+ return batchUpdateProfilesRun(options);
359
+ },
360
+ });
252
361
  // ── Labels sub-resource ──
253
362
  exports.profilesLabels = incur_1.Cli.create('labels', {
254
363
  description: 'Manage labels on a wallet profile',
@@ -267,10 +376,15 @@ function buildCreateLabelBody(options) {
267
376
  }
268
377
  if (options.tagId) {
269
378
  const single = { tag_id: options.tagId };
270
- if (options.value)
379
+ if (options.value !== undefined)
271
380
  single.value = options.value;
272
381
  if (options.chainId)
273
382
  single.chain_id = options.chainId;
383
+ if (options.timestamp)
384
+ single.timestamp = options.timestamp;
385
+ if (options.isDeleted !== undefined) {
386
+ single._is_deleted = options.isDeleted ? 1 : 0;
387
+ }
274
388
  return single;
275
389
  }
276
390
  throw new Error('Provide --tag-id (single label) or --labels (batch JSON array)');
@@ -292,10 +406,18 @@ exports.profilesLabels.command('create', {
292
406
  .describe('Label identifier (e.g. "vip", "airdrop_eligible")'),
293
407
  value: incur_1.z.string().optional().describe('Optional label value (e.g. tier name, country code)'),
294
408
  chainId: incur_1.z.string().optional().describe('Optional chain identifier the label applies to'),
409
+ timestamp: incur_1.z
410
+ .string()
411
+ .optional()
412
+ .describe('Optional historical ISO-8601 timestamp for the label row'),
413
+ isDeleted: incur_1.z
414
+ .boolean()
415
+ .optional()
416
+ .describe('Set true with --timestamp to backfill a label removal tombstone'),
295
417
  labels: incur_1.z
296
418
  .string()
297
419
  .optional()
298
- .describe('JSON array of UserLabelInput objects for batch upsert'),
420
+ .describe('JSON array of UserLabelInput objects for this wallet'),
299
421
  }),
300
422
  examples: [
301
423
  {
@@ -308,6 +430,11 @@ exports.profilesLabels.command('create', {
308
430
  options: { tagId: 'tier', value: 'gold', chainId: '1' },
309
431
  description: 'Apply a tiered label scoped to a chain',
310
432
  },
433
+ {
434
+ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
435
+ options: { tagId: 'tier', timestamp: '2024-03-15T00:00:00.000Z', isDeleted: true },
436
+ description: 'Backfill a historical label removal',
437
+ },
311
438
  {
312
439
  args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
313
440
  options: { labels: '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]' },
@@ -319,6 +446,46 @@ exports.profilesLabels.command('create', {
319
446
  return createProfileLabelRun(args.address, options);
320
447
  },
321
448
  });
449
+ function buildBatchCreateLabelsBody(options) {
450
+ const labels = (0, json_1.parseJsonArrayOfObjects)(options.labels, '--labels');
451
+ if (labels.length === 0) {
452
+ throw new Error('--labels must contain at least one item');
453
+ }
454
+ for (const label of labels) {
455
+ if (typeof label.address !== 'string' || label.address.length === 0) {
456
+ throw new Error('--labels entries must each include a non-empty string address');
457
+ }
458
+ if (typeof label.tag_id !== 'string' || label.tag_id.length === 0) {
459
+ throw new Error('--labels entries must each include a non-empty string tag_id');
460
+ }
461
+ }
462
+ return labels;
463
+ }
464
+ function batchCreateProfileLabelsRun(options) {
465
+ (0, client_1.requireApiKey)();
466
+ const client = (0, client_1.createClient)();
467
+ return client.post('/v0/profiles/labels', buildBatchCreateLabelsBody(options));
468
+ }
469
+ exports.profilesLabels.command('batch', {
470
+ description: 'Batch upsert labels across up to 100 wallets',
471
+ options: incur_1.z.object({
472
+ labels: incur_1.z
473
+ .string()
474
+ .describe('JSON array of {address,tag_id,value?,chain_id?,timestamp?,_is_deleted?} objects'),
475
+ }),
476
+ examples: [
477
+ {
478
+ options: {
479
+ labels: '[{"address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","tag_id":"vip","value":"tier-1"}]',
480
+ },
481
+ description: 'Batch upsert labels for multiple wallets',
482
+ },
483
+ ],
484
+ hint: 'Requires profiles:write scope on your API key. ENS names are not resolved in batch requests.',
485
+ run({ options }) {
486
+ return batchCreateProfileLabelsRun(options);
487
+ },
488
+ });
322
489
  function buildDeleteLabelBody(options) {
323
490
  if (!options.tagId) {
324
491
  throw new Error('--tag-id is required');
@@ -359,4 +526,5 @@ exports.profilesLabels.command('delete', {
359
526
  return deleteProfileLabelRun(args.address, options);
360
527
  },
361
528
  });
529
+ exports.profiles.command(exports.profilesProperties);
362
530
  exports.profiles.command(exports.profilesLabels);
@@ -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
- return client.post('/v0/query/', { query: sql });
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 declare function listSegmentsRun(): Promise<import("axios").AxiosResponse<any, any, {}>>;
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: unknown;
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
- // ── List segments ──
14
- function listSegmentsRun() {
13
+ function buildPaginationParams(options = {}) {
14
+ const params = {};
15
+ if (options.page !== undefined)
16
+ params.page = options.page;
17
+ if (options.size !== undefined)
18
+ params.size = options.size;
19
+ return params;
20
+ }
21
+ function listSegmentsRun(options = {}) {
15
22
  (0, client_1.requireApiKey)();
16
23
  const client = (0, client_1.createClient)();
17
- return client.get('/v0/segments/');
24
+ return client.get('/v0/segments/', { params: buildPaginationParams(options) });
18
25
  }
19
26
  exports.segments.command('list', {
20
27
  description: 'List all user segments for the project',
28
+ options: incur_1.z.object({
29
+ page: incur_1.z.coerce.number().optional().describe('Page number (1-indexed, default 1)'),
30
+ size: incur_1.z.coerce.number().optional().describe('Page size (default 100, max 200)'),
31
+ }),
21
32
  examples: [{ description: 'List all project segments' }],
22
33
  hint: 'Requires segments:read scope on your API key.',
23
- run() {
24
- return listSegmentsRun();
34
+ run({ options }) {
35
+ return listSegmentsRun(options);
25
36
  },
26
37
  });
27
38
  function buildCreateSegmentBody(options) {
28
39
  let parsedFilterSets;
29
40
  try {
30
41
  parsedFilterSets = JSON.parse(options.filterSets);
42
+ if (!Array.isArray(parsedFilterSets)) {
43
+ throw new Error('not an array');
44
+ }
31
45
  }
32
46
  catch {
33
47
  throw new Error('--filter-sets must be a valid JSON array');
package/dist/index.js CHANGED
@@ -7,22 +7,23 @@ const analytics_1 = require("./commands/analytics");
7
7
  const boards_1 = require("./commands/boards");
8
8
  const charts_1 = require("./commands/charts");
9
9
  const contracts_1 = require("./commands/contracts");
10
+ const events_1 = require("./commands/events");
10
11
  const import_1 = require("./commands/import");
11
12
  const profiles_1 = require("./commands/profiles");
12
13
  const query_1 = require("./commands/query");
13
14
  const segments_1 = require("./commands/segments");
15
+ const client_1 = require("./lib/client");
14
16
  const config_1 = require("./lib/config");
15
17
  const ui_1 = require("./lib/ui");
16
18
  const DASHBOARD_URL = "https://app.formo.so";
17
19
  const DOCS_URL = "https://docs.formo.so";
18
- const API_BASE_URL = "https://api.formo.so";
19
20
  function loginGuide() {
20
21
  return [
21
22
  "",
22
23
  ui_1.color.boldGreen("How to get your API key:"),
23
24
  "",
24
25
  ` ${ui_1.color.white("1.")} Go to ${ui_1.color.cyan(DASHBOARD_URL)}`,
25
- ` ${ui_1.color.white("2.")} Navigate to ${ui_1.color.bold("Settings")} → ${ui_1.color.bold("API Keys")}`,
26
+ ` ${ui_1.color.white("2.")} Navigate to ${ui_1.color.bold("Settings")} → ${ui_1.color.bold("API")}`,
26
27
  ` ${ui_1.color.white("3.")} Click ${ui_1.color.bold('"Create API Key"')} and copy the key`,
27
28
  ` ${ui_1.color.white("4.")} Run:`,
28
29
  "",
@@ -38,7 +39,7 @@ function loginGuide() {
38
39
  }
39
40
  async function validateAndFetchWorkspace(apiKey) {
40
41
  try {
41
- const res = await fetch(`${API_BASE_URL}/api/validate-api-key`, {
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.2.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)
@@ -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
- declare function createClient(): import("axios").AxiosInstance;
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 };
@@ -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
- const BASE_URL = 'https://api.formo.so';
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 = BASE_URL;
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.');
@@ -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[];
@@ -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
+ }