@formo/cli 1.0.2 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -11
- package/dist/commands/alerts.d.ts +14 -22
- package/dist/commands/alerts.js +26 -48
- package/dist/commands/analytics.d.ts +1 -1
- package/dist/commands/analytics.js +4 -12
- 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 +10 -14
- 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 +7 -7
- package/dist/commands/profiles.js +24 -11
- package/dist/commands/query.d.ts +1 -1
- package/dist/commands/segments.d.ts +6 -8
- package/dist/commands/segments.js +8 -22
- package/dist/index.js +40 -27
- 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/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 +10 -13
|
@@ -14,17 +14,10 @@ exports.deleteContractRun = deleteContractRun;
|
|
|
14
14
|
const incur_1 = require("incur");
|
|
15
15
|
const client_1 = require("../lib/client");
|
|
16
16
|
const json_1 = require("../lib/json");
|
|
17
|
+
const pagination_1 = require("../lib/pagination");
|
|
17
18
|
exports.contracts = incur_1.Cli.create('contracts', {
|
|
18
19
|
description: 'Smart contract commands — register, list, recommend, update, toggle pipeline inclusion, and remove tracked contracts',
|
|
19
20
|
});
|
|
20
|
-
function buildPaginationParams(options = {}) {
|
|
21
|
-
const params = {};
|
|
22
|
-
if (options.page !== undefined)
|
|
23
|
-
params.page = options.page;
|
|
24
|
-
if (options.size !== undefined)
|
|
25
|
-
params.size = options.size;
|
|
26
|
-
return params;
|
|
27
|
-
}
|
|
28
21
|
function parseChain(chain) {
|
|
29
22
|
const value = typeof chain === 'number' ? chain : Number(chain);
|
|
30
23
|
if (!Number.isInteger(value) || value < 1) {
|
|
@@ -37,15 +30,12 @@ function listContractsRun(options = {}) {
|
|
|
37
30
|
(0, client_1.requireApiKey)();
|
|
38
31
|
const client = (0, client_1.createClient)();
|
|
39
32
|
return client.get('/v0/contracts/', {
|
|
40
|
-
params: buildPaginationParams(options),
|
|
33
|
+
params: (0, pagination_1.buildPaginationParams)(options),
|
|
41
34
|
});
|
|
42
35
|
}
|
|
43
36
|
exports.contracts.command('list', {
|
|
44
37
|
description: 'List all tracked contracts for the project',
|
|
45
|
-
options: incur_1.z.object(
|
|
46
|
-
page: incur_1.z.coerce.number().optional().describe('Page number (1-indexed, default 1)'),
|
|
47
|
-
size: incur_1.z.coerce.number().optional().describe('Page size (default 100, max 200)'),
|
|
48
|
-
}),
|
|
38
|
+
options: incur_1.z.object(pagination_1.paginationOptionsSchema),
|
|
49
39
|
examples: [{ description: 'List all project contracts' }],
|
|
50
40
|
hint: 'Requires contracts:read scope on your API key.',
|
|
51
41
|
run({ options }) {
|
|
@@ -56,7 +46,7 @@ exports.contracts.command('list', {
|
|
|
56
46
|
function getContractRun(chain, address) {
|
|
57
47
|
(0, client_1.requireApiKey)();
|
|
58
48
|
const client = (0, client_1.createClient)();
|
|
59
|
-
return client.get(`/v0/contracts/${
|
|
49
|
+
return client.get(`/v0/contracts/${parseChain(chain)}/${encodeURIComponent(address)}`);
|
|
60
50
|
}
|
|
61
51
|
// ── Recommended contracts ──
|
|
62
52
|
function getContractRecommendationsRun() {
|
|
@@ -170,7 +160,7 @@ function buildUpdateContractBody(chain, address, options) {
|
|
|
170
160
|
function updateContractRun(chain, address, options) {
|
|
171
161
|
(0, client_1.requireApiKey)();
|
|
172
162
|
const client = (0, client_1.createClient)();
|
|
173
|
-
return client.put(`/v0/contracts/${
|
|
163
|
+
return client.put(`/v0/contracts/${parseChain(chain)}/${encodeURIComponent(address)}`, buildUpdateContractBody(chain, address, options));
|
|
174
164
|
}
|
|
175
165
|
exports.contracts.command('update', {
|
|
176
166
|
description: 'Update a tracked contract',
|
|
@@ -211,7 +201,7 @@ exports.contracts.command('update', {
|
|
|
211
201
|
function updateContractPipelineRun(chain, address, includeInPipeline) {
|
|
212
202
|
(0, client_1.requireApiKey)();
|
|
213
203
|
const client = (0, client_1.createClient)();
|
|
214
|
-
return client.patch(`/v0/contracts/${
|
|
204
|
+
return client.patch(`/v0/contracts/${parseChain(chain)}/${encodeURIComponent(address)}/pipeline`, buildUpdateContractPipelineBody(includeInPipeline));
|
|
215
205
|
}
|
|
216
206
|
function buildUpdateContractPipelineBody(includeInPipeline) {
|
|
217
207
|
return { include_in_pipeline: includeInPipeline };
|
|
@@ -246,7 +236,7 @@ exports.contracts.command('pipeline', {
|
|
|
246
236
|
function deleteContractRun(chain, address) {
|
|
247
237
|
(0, client_1.requireApiKey)();
|
|
248
238
|
const client = (0, client_1.createClient)();
|
|
249
|
-
return client.delete(`/v0/contracts/${
|
|
239
|
+
return client.delete(`/v0/contracts/${parseChain(chain)}/${encodeURIComponent(address)}`);
|
|
250
240
|
}
|
|
251
241
|
exports.contracts.command('delete', {
|
|
252
242
|
description: 'Remove a tracked contract',
|
|
@@ -6,4 +6,4 @@ export interface IngestEventsOptions {
|
|
|
6
6
|
writeKey?: string;
|
|
7
7
|
}
|
|
8
8
|
export declare function buildIngestEventsBody(options: IngestEventsOptions): Record<string, unknown>[];
|
|
9
|
-
export declare function ingestEventsRun(options: IngestEventsOptions): Promise<
|
|
9
|
+
export declare function ingestEventsRun(options: IngestEventsOptions): Promise<unknown>;
|
package/dist/commands/events.js
CHANGED
|
@@ -13,6 +13,9 @@ function getWriteKey(options) {
|
|
|
13
13
|
return options.writeKey ?? process.env.FORMO_WRITE_KEY;
|
|
14
14
|
}
|
|
15
15
|
function buildIngestEventsBody(options) {
|
|
16
|
+
if (options.events && options.event) {
|
|
17
|
+
throw new Error('Provide only one of --event or --events');
|
|
18
|
+
}
|
|
16
19
|
if (options.events) {
|
|
17
20
|
const events = (0, json_1.parseJsonArrayOfObjects)(options.events, '--events');
|
|
18
21
|
if (events.length === 0) {
|
|
@@ -26,11 +29,9 @@ function buildIngestEventsBody(options) {
|
|
|
26
29
|
throw new Error('Provide --event or --events');
|
|
27
30
|
}
|
|
28
31
|
function ingestEventsRun(options) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
const client = (0, client_1.createEventsClient)(writeKey);
|
|
32
|
+
// createEventsClient throws the "No event write key configured" error for
|
|
33
|
+
// a missing key — no need to duplicate the guard here.
|
|
34
|
+
const client = (0, client_1.createEventsClient)(getWriteKey(options) ?? '');
|
|
34
35
|
return client.post('/v0/raw_events', buildIngestEventsBody(options));
|
|
35
36
|
}
|
|
36
37
|
exports.events.command('ingest', {
|
|
@@ -12,4 +12,4 @@ export declare function buildImportBody(options: ImportWalletsOptions): {
|
|
|
12
12
|
addresses: unknown[];
|
|
13
13
|
rows?: undefined;
|
|
14
14
|
};
|
|
15
|
-
export declare function importWalletsRun(options: ImportWalletsOptions): Promise<
|
|
15
|
+
export declare function importWalletsRun(options: ImportWalletsOptions): Promise<unknown>;
|
package/dist/commands/import.js
CHANGED
|
@@ -10,6 +10,9 @@ exports.importCmd = incur_1.Cli.create('import', {
|
|
|
10
10
|
description: 'Import commands — bulk import wallet addresses into your project',
|
|
11
11
|
});
|
|
12
12
|
function buildImportBody(options) {
|
|
13
|
+
if (options.rows && options.addresses) {
|
|
14
|
+
throw new Error('Provide only one of --addresses or --rows');
|
|
15
|
+
}
|
|
13
16
|
if (options.rows) {
|
|
14
17
|
const rows = (0, json_1.parseJsonArrayOfObjects)(options.rows, '--rows');
|
|
15
18
|
const addresses = rows.map((row) => row.address);
|
|
@@ -12,7 +12,7 @@ export interface LifecycleThresholdOptions {
|
|
|
12
12
|
export interface GetProfileOptions extends LifecycleThresholdOptions {
|
|
13
13
|
expand?: string;
|
|
14
14
|
}
|
|
15
|
-
export declare function getProfileRun(address: string, optionsOrExpand?: GetProfileOptions | string): Promise<
|
|
15
|
+
export declare function getProfileRun(address: string, optionsOrExpand?: GetProfileOptions | string): Promise<unknown>;
|
|
16
16
|
export interface SearchProfilesOptions extends LifecycleThresholdOptions {
|
|
17
17
|
address?: string;
|
|
18
18
|
search?: string;
|
|
@@ -31,17 +31,17 @@ export interface SearchProfilesOptions extends LifecycleThresholdOptions {
|
|
|
31
31
|
* dropped by the API, so it is rejected here. Exported for unit testing.
|
|
32
32
|
*/
|
|
33
33
|
export declare function parseSearchConditions(raw: string): unknown[];
|
|
34
|
-
export declare function searchProfilesRun(options: SearchProfilesOptions): Promise<
|
|
34
|
+
export declare function searchProfilesRun(options: SearchProfilesOptions): Promise<unknown>;
|
|
35
35
|
export interface UpdateProfileOptions {
|
|
36
36
|
properties: string;
|
|
37
37
|
}
|
|
38
38
|
export declare function buildUpdateProfileBody(options: UpdateProfileOptions): Record<string, unknown>;
|
|
39
|
-
export declare function updateProfileRun(address: string, options: UpdateProfileOptions): Promise<
|
|
39
|
+
export declare function updateProfileRun(address: string, options: UpdateProfileOptions): Promise<unknown>;
|
|
40
40
|
export interface BatchUpdateProfilesOptions {
|
|
41
41
|
rows: string;
|
|
42
42
|
}
|
|
43
43
|
export declare function buildBatchUpdateProfilesBody(options: BatchUpdateProfilesOptions): Record<string, unknown>[];
|
|
44
|
-
export declare function batchUpdateProfilesRun(options: BatchUpdateProfilesOptions): Promise<
|
|
44
|
+
export declare function batchUpdateProfilesRun(options: BatchUpdateProfilesOptions): Promise<unknown>;
|
|
45
45
|
export declare const profilesProperties: Cli.Cli<{}, undefined, undefined>;
|
|
46
46
|
export declare const profilesLabels: Cli.Cli<{}, undefined, undefined>;
|
|
47
47
|
export interface CreateProfileLabelOptions {
|
|
@@ -53,15 +53,15 @@ export interface CreateProfileLabelOptions {
|
|
|
53
53
|
labels?: string;
|
|
54
54
|
}
|
|
55
55
|
export declare function buildCreateLabelBody(options: CreateProfileLabelOptions): unknown;
|
|
56
|
-
export declare function createProfileLabelRun(address: string, options: CreateProfileLabelOptions): Promise<
|
|
56
|
+
export declare function createProfileLabelRun(address: string, options: CreateProfileLabelOptions): Promise<unknown>;
|
|
57
57
|
export interface BatchCreateProfileLabelsOptions {
|
|
58
58
|
labels: string;
|
|
59
59
|
}
|
|
60
60
|
export declare function buildBatchCreateLabelsBody(options: BatchCreateProfileLabelsOptions): Record<string, unknown>[];
|
|
61
|
-
export declare function batchCreateProfileLabelsRun(options: BatchCreateProfileLabelsOptions): Promise<
|
|
61
|
+
export declare function batchCreateProfileLabelsRun(options: BatchCreateProfileLabelsOptions): Promise<unknown>;
|
|
62
62
|
export interface DeleteProfileLabelOptions {
|
|
63
63
|
tagId: string;
|
|
64
64
|
chainId?: string;
|
|
65
65
|
}
|
|
66
66
|
export declare function buildDeleteLabelBody(options: DeleteProfileLabelOptions): Record<string, string>;
|
|
67
|
-
export declare function deleteProfileLabelRun(address: string, options: DeleteProfileLabelOptions): Promise<
|
|
67
|
+
export declare function deleteProfileLabelRun(address: string, options: DeleteProfileLabelOptions): Promise<unknown>;
|
|
@@ -199,8 +199,18 @@ exports.profiles.command('search', {
|
|
|
199
199
|
options: incur_1.z.object({
|
|
200
200
|
address: incur_1.z.string().optional().describe('Filter by wallet address'),
|
|
201
201
|
search: incur_1.z.string().optional().describe('Free-text search across address and identity fields'),
|
|
202
|
-
page: incur_1.z.coerce
|
|
203
|
-
|
|
202
|
+
page: incur_1.z.coerce
|
|
203
|
+
.number()
|
|
204
|
+
.int()
|
|
205
|
+
.positive()
|
|
206
|
+
.optional()
|
|
207
|
+
.describe('Page number (1-indexed, default 1)'),
|
|
208
|
+
size: incur_1.z.coerce
|
|
209
|
+
.number()
|
|
210
|
+
.int()
|
|
211
|
+
.positive()
|
|
212
|
+
.optional()
|
|
213
|
+
.describe('Page size (default 100, max 1000)'),
|
|
204
214
|
orderBy: incur_1.z
|
|
205
215
|
.enum([
|
|
206
216
|
'last_onchain',
|
|
@@ -230,7 +240,9 @@ exports.profiles.command('search', {
|
|
|
230
240
|
'Chains: chains.balance or chains.{chain_id}.balance. ' +
|
|
231
241
|
'Apps: apps.{app_id}.balance. Tokens: tokens.{address}.balance ' +
|
|
232
242
|
'(optional "scope":"any"|"protocol" + "appId"). Labels: labels.{tag_id}. ' +
|
|
233
|
-
'op: eq, neq, gt, gte, lt, lte, in, nin
|
|
243
|
+
'op: eq, neq, gt, gte, lt, lte, in, nin, contains, notEmpty, isEmpty ' +
|
|
244
|
+
'(contains = substring, social fields only; notEmpty/isEmpty = value-less existence checks on string fields). ' +
|
|
245
|
+
'Long-form spellings (equals, notEquals, greater, greaterOrEqual, less, lessOrEqual, notIn, includes) are retired; the API rejects them with a 400 naming the token.'),
|
|
234
246
|
logic: incur_1.z
|
|
235
247
|
.enum(['and', 'or'])
|
|
236
248
|
.optional()
|
|
@@ -271,7 +283,7 @@ exports.profiles.command('search', {
|
|
|
271
283
|
},
|
|
272
284
|
],
|
|
273
285
|
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.',
|
|
274
|
-
run({
|
|
286
|
+
run({ options }) {
|
|
275
287
|
return searchProfilesRun(options);
|
|
276
288
|
},
|
|
277
289
|
});
|
|
@@ -362,15 +374,16 @@ exports.profilesLabels = incur_1.Cli.create('labels', {
|
|
|
362
374
|
});
|
|
363
375
|
function buildCreateLabelBody(options) {
|
|
364
376
|
if (options.labels) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
throw new Error('not a non-empty array');
|
|
369
|
-
return parsed;
|
|
377
|
+
const parsed = (0, json_1.parseJsonArrayOfObjects)(options.labels, '--labels');
|
|
378
|
+
if (parsed.length === 0) {
|
|
379
|
+
throw new Error('--labels must contain at least one item');
|
|
370
380
|
}
|
|
371
|
-
|
|
372
|
-
|
|
381
|
+
for (const label of parsed) {
|
|
382
|
+
if (typeof label.tag_id !== 'string' || label.tag_id.length === 0) {
|
|
383
|
+
throw new Error('--labels entries must each include a non-empty string tag_id');
|
|
384
|
+
}
|
|
373
385
|
}
|
|
386
|
+
return parsed;
|
|
374
387
|
}
|
|
375
388
|
if (options.tagId) {
|
|
376
389
|
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
|
filterSets: string;
|
|
11
9
|
}
|
|
12
10
|
export declare function buildCreateSegmentBody(options: CreateSegmentOptions): {
|
|
13
11
|
title: string;
|
|
14
|
-
filterSets:
|
|
12
|
+
filterSets: 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,28 +7,20 @@ 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 json_1 = require("../lib/json");
|
|
11
|
+
const pagination_1 = require("../lib/pagination");
|
|
10
12
|
exports.segments = incur_1.Cli.create('segments', {
|
|
11
13
|
description: 'User segment commands — create, list, and delete audience segments',
|
|
12
14
|
});
|
|
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
|
-
}
|
|
15
|
+
// ── List segments ──
|
|
21
16
|
function listSegmentsRun(options = {}) {
|
|
22
17
|
(0, client_1.requireApiKey)();
|
|
23
18
|
const client = (0, client_1.createClient)();
|
|
24
|
-
return client.get('/v0/segments/', { params: buildPaginationParams(options) });
|
|
19
|
+
return client.get('/v0/segments/', { params: (0, pagination_1.buildPaginationParams)(options) });
|
|
25
20
|
}
|
|
26
21
|
exports.segments.command('list', {
|
|
27
22
|
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
|
-
}),
|
|
23
|
+
options: incur_1.z.object(pagination_1.paginationOptionsSchema),
|
|
32
24
|
examples: [{ description: 'List all project segments' }],
|
|
33
25
|
hint: 'Requires segments:read scope on your API key.',
|
|
34
26
|
run({ options }) {
|
|
@@ -36,15 +28,9 @@ exports.segments.command('list', {
|
|
|
36
28
|
},
|
|
37
29
|
});
|
|
38
30
|
function buildCreateSegmentBody(options) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (!Array.isArray(parsedFilterSets)) {
|
|
43
|
-
throw new Error('not an array');
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
throw new Error('--filter-sets must be a valid JSON array');
|
|
31
|
+
const parsedFilterSets = (0, json_1.parseJsonArray)(options.filterSets, '--filter-sets');
|
|
32
|
+
if (parsedFilterSets.some((item) => typeof item !== 'string')) {
|
|
33
|
+
throw new Error('--filter-sets must be a JSON array of strings');
|
|
48
34
|
}
|
|
49
35
|
return {
|
|
50
36
|
title: options.title,
|
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() {
|
|
@@ -23,7 +28,7 @@ function loginGuide() {
|
|
|
23
28
|
ui_1.color.boldGreen("How to get your API key:"),
|
|
24
29
|
"",
|
|
25
30
|
` ${ui_1.color.white("1.")} Go to ${ui_1.color.cyan(DASHBOARD_URL)}`,
|
|
26
|
-
` ${ui_1.color.white("2.")} Navigate to ${ui_1.color.bold("Settings")} → ${ui_1.color.bold("API
|
|
31
|
+
` ${ui_1.color.white("2.")} Navigate to ${ui_1.color.bold("Settings")} → ${ui_1.color.bold("API")}`,
|
|
27
32
|
` ${ui_1.color.white("3.")} Click ${ui_1.color.bold('"Create API Key"')} and copy the key`,
|
|
28
33
|
` ${ui_1.color.white("4.")} Run:`,
|
|
29
34
|
"",
|
|
@@ -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
|
}
|
package/dist/lib/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AxiosError } from 'axios';
|
|
1
|
+
import { AxiosError, type AxiosInstance, type AxiosRequestConfig } from 'axios';
|
|
2
2
|
export declare const DEFAULT_API_BASE_URL = "https://api.formo.so";
|
|
3
3
|
export declare const DEFAULT_EVENTS_BASE_URL = "https://events.formo.so";
|
|
4
4
|
export declare function getApiBaseUrl(): string;
|
|
@@ -32,7 +32,22 @@ export interface ClientOptions {
|
|
|
32
32
|
baseURL?: string;
|
|
33
33
|
apiKey?: string;
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* The response interceptor below unwraps every response to its body
|
|
37
|
+
* (`res.data`), so the raw AxiosInstance types would lie — they promise
|
|
38
|
+
* `AxiosResponse<T>` while the runtime value is the body itself. This
|
|
39
|
+
* interface states what actually comes back.
|
|
40
|
+
*/
|
|
41
|
+
export interface FormoClient {
|
|
42
|
+
get(url: string, config?: AxiosRequestConfig): Promise<unknown>;
|
|
43
|
+
post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<unknown>;
|
|
44
|
+
put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<unknown>;
|
|
45
|
+
patch(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<unknown>;
|
|
46
|
+
delete(url: string, config?: AxiosRequestConfig): Promise<unknown>;
|
|
47
|
+
request(config: AxiosRequestConfig): Promise<unknown>;
|
|
48
|
+
defaults: AxiosInstance['defaults'];
|
|
49
|
+
}
|
|
50
|
+
declare function createClient(options?: ClientOptions): FormoClient;
|
|
51
|
+
export declare function createEventsClient(writeKey: string): FormoClient;
|
|
37
52
|
export declare function requireApiKey(): void;
|
|
38
53
|
export { createClient };
|
package/dist/lib/client.js
CHANGED
|
@@ -38,7 +38,9 @@ function parseApiError(error) {
|
|
|
38
38
|
parts.push(`Param: ${apiError.param}`);
|
|
39
39
|
if (apiError?.details && Object.keys(apiError.details).length > 0) {
|
|
40
40
|
const details = Object.entries(apiError.details)
|
|
41
|
-
.map(([key, value]) => `${key}: ${
|
|
41
|
+
.map(([key, value]) => `${key}: ${typeof value === 'object' && value !== null
|
|
42
|
+
? JSON.stringify(value)
|
|
43
|
+
: String(value)}`)
|
|
42
44
|
.join('; ');
|
|
43
45
|
parts.push(`Details: ${details}`);
|
|
44
46
|
}
|
|
@@ -57,17 +59,24 @@ function parseApiError(error) {
|
|
|
57
59
|
function createClient(options = {}) {
|
|
58
60
|
const apiKey = options.apiKey ?? (0, config_1.getApiKey)();
|
|
59
61
|
const baseURL = options.baseURL ?? getApiBaseUrl();
|
|
62
|
+
// Fail here, not with a server-side 401: every authenticated command needs
|
|
63
|
+
// a key, and this guard catches call sites that forget requireApiKey().
|
|
64
|
+
if (!apiKey) {
|
|
65
|
+
throw new Error('No API key configured. Run `formo login <apiKey>` or set FORMO_API_KEY env var.');
|
|
66
|
+
}
|
|
60
67
|
const instance = axios_1.default.create({
|
|
61
68
|
baseURL,
|
|
62
69
|
timeout: 30000,
|
|
63
70
|
headers: {
|
|
64
71
|
'Content-Type': 'application/json',
|
|
65
|
-
|
|
72
|
+
Authorization: `Bearer ${apiKey}`,
|
|
66
73
|
},
|
|
67
74
|
});
|
|
68
75
|
instance.interceptors.response.use((res) => res.data, (error) => {
|
|
69
76
|
throw parseApiError(error);
|
|
70
77
|
});
|
|
78
|
+
// The interceptor changes what the promise resolves to; the cast makes the
|
|
79
|
+
// public type match the runtime behavior (see FormoClient above).
|
|
71
80
|
return instance;
|
|
72
81
|
}
|
|
73
82
|
function createEventsClient(writeKey) {
|
package/dist/lib/config.d.ts
CHANGED
package/dist/lib/config.js
CHANGED
|
@@ -3,6 +3,7 @@ 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.getConfigFile = getConfigFile;
|
|
6
7
|
exports.readConfig = readConfig;
|
|
7
8
|
exports.saveConfig = saveConfig;
|
|
8
9
|
exports.clearConfig = clearConfig;
|
|
@@ -10,12 +11,24 @@ exports.getApiKey = getApiKey;
|
|
|
10
11
|
const fs_1 = __importDefault(require("fs"));
|
|
11
12
|
const os_1 = __importDefault(require("os"));
|
|
12
13
|
const path_1 = __importDefault(require("path"));
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
// FORMO_CONFIG_DIR lets tests (and users) redirect config away from the real
|
|
15
|
+
// ~/.config/formo — resolved lazily so a test can set it after import.
|
|
16
|
+
function configDir() {
|
|
17
|
+
return (process.env.FORMO_CONFIG_DIR ?? path_1.default.join(os_1.default.homedir(), '.config', 'formo'));
|
|
18
|
+
}
|
|
19
|
+
function getConfigFile() {
|
|
20
|
+
return path_1.default.join(configDir(), 'config.json');
|
|
21
|
+
}
|
|
15
22
|
function readConfig() {
|
|
16
23
|
try {
|
|
17
|
-
const raw = fs_1.default.readFileSync(
|
|
18
|
-
|
|
24
|
+
const raw = fs_1.default.readFileSync(getConfigFile(), 'utf-8');
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
// JSON.parse happily returns null/"abc"/[1] — treat anything that isn't a
|
|
27
|
+
// plain object as an empty config instead of crashing later callers.
|
|
28
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
return parsed;
|
|
19
32
|
}
|
|
20
33
|
catch {
|
|
21
34
|
return {};
|
|
@@ -29,26 +42,27 @@ function saveConfig(updates) {
|
|
|
29
42
|
// tool, another app under ~/.config) keeps its old, possibly
|
|
30
43
|
// group/world-readable perms — leaking the plaintext API key on a
|
|
31
44
|
// multi-user host. chmod unconditionally so 0o700/0o600 always holds.
|
|
32
|
-
fs_1.default.mkdirSync(
|
|
33
|
-
fs_1.default.chmodSync(
|
|
34
|
-
fs_1.default.writeFileSync(
|
|
45
|
+
fs_1.default.mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
46
|
+
fs_1.default.chmodSync(configDir(), 0o700);
|
|
47
|
+
fs_1.default.writeFileSync(getConfigFile(), JSON.stringify(merged, null, 2), {
|
|
35
48
|
mode: 0o600,
|
|
36
49
|
});
|
|
37
|
-
fs_1.default.chmodSync(
|
|
50
|
+
fs_1.default.chmodSync(getConfigFile(), 0o600);
|
|
38
51
|
}
|
|
39
52
|
function clearConfig() {
|
|
40
53
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
fs_1.default.chmodSync(CONFIG_FILE, 0o600);
|
|
48
|
-
}
|
|
54
|
+
fs_1.default.writeFileSync(getConfigFile(), JSON.stringify({}, null, 2), {
|
|
55
|
+
mode: 0o600,
|
|
56
|
+
});
|
|
57
|
+
// Same create-only-mode caveat as saveConfig: enforce 0o600 on the
|
|
58
|
+
// already-existing file so the cleared config can't be left readable.
|
|
59
|
+
fs_1.default.chmodSync(getConfigFile(), 0o600);
|
|
49
60
|
}
|
|
50
|
-
catch {
|
|
51
|
-
//
|
|
61
|
+
catch (err) {
|
|
62
|
+
// Nothing to clear is fine; a real write failure (EACCES, EROFS) must
|
|
63
|
+
// surface so `formo logout` can't claim success while the key remains.
|
|
64
|
+
if (err.code !== 'ENOENT')
|
|
65
|
+
throw err;
|
|
52
66
|
}
|
|
53
67
|
}
|
|
54
68
|
function getApiKey() {
|