@formo/cli 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const incur_1 = require("incur");
5
5
  const alerts_1 = require("./commands/alerts");
6
+ const analytics_1 = require("./commands/analytics");
6
7
  const boards_1 = require("./commands/boards");
7
8
  const charts_1 = require("./commands/charts");
8
9
  const contracts_1 = require("./commands/contracts");
@@ -12,213 +13,217 @@ const query_1 = require("./commands/query");
12
13
  const segments_1 = require("./commands/segments");
13
14
  const config_1 = require("./lib/config");
14
15
  const ui_1 = require("./lib/ui");
15
- const DASHBOARD_URL = 'https://app.formo.so';
16
- const DOCS_URL = 'https://docs.formo.so';
17
- const API_BASE_URL = 'https://api.formo.so';
16
+ const DASHBOARD_URL = "https://app.formo.so";
17
+ const DOCS_URL = "https://docs.formo.so";
18
+ const API_BASE_URL = "https://api.formo.so";
18
19
  function loginGuide() {
19
20
  return [
20
- '',
21
- ui_1.color.boldGreen('How to get your API key:'),
22
- '',
23
- ` ${ui_1.color.white('1.')} Go to ${ui_1.color.cyan(DASHBOARD_URL)}`,
24
- ` ${ui_1.color.white('2.')} Navigate to ${ui_1.color.bold('Settings')} → ${ui_1.color.bold('API Keys')}`,
25
- ` ${ui_1.color.white('3.')} Click ${ui_1.color.bold('"Create API Key"')} and copy the key`,
26
- ` ${ui_1.color.white('4.')} Run:`,
27
- '',
28
- ` ${ui_1.color.green('formo login <formo_your_api_key_here>')}`,
29
- '',
21
+ "",
22
+ ui_1.color.boldGreen("How to get your API key:"),
23
+ "",
24
+ ` ${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("3.")} Click ${ui_1.color.bold('"Create API Key"')} and copy the key`,
27
+ ` ${ui_1.color.white("4.")} Run:`,
28
+ "",
29
+ ` ${ui_1.color.green("formo login <formo_your_api_key_here>")}`,
30
+ "",
30
31
  ui_1.color.dim(` Or set the environment variable:`),
31
- '',
32
- ` ${ui_1.color.green('export FORMO_API_KEY=formo_your_api_key_here')}`,
33
- '',
32
+ "",
33
+ ` ${ui_1.color.green("export FORMO_API_KEY=formo_your_api_key_here")}`,
34
+ "",
34
35
  ui_1.color.dim(` Docs: ${DOCS_URL}`),
35
- '',
36
- ].join('\n');
36
+ "",
37
+ ].join("\n");
37
38
  }
38
39
  async function validateAndFetchWorkspace(apiKey) {
39
40
  try {
40
41
  const res = await fetch(`${API_BASE_URL}/api/validate-api-key`, {
41
- method: 'POST',
42
- headers: { 'Content-Type': 'application/json' },
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/json" },
43
44
  body: JSON.stringify({ apiKey }),
44
45
  });
45
46
  if (!res.ok)
46
47
  return null;
47
48
  const body = (await res.json());
48
- if (!body.isSuccess || !body.data)
49
+ if (!body.validated)
49
50
  return null;
50
51
  return {
51
- workspace: body.data.details,
52
- projectId: body.data.scopes?.project_id ?? '',
52
+ workspace: body.details,
53
+ projectId: body.scopes?.project_id ?? "",
53
54
  };
54
55
  }
55
56
  catch {
56
57
  return null;
57
58
  }
58
59
  }
59
- const cli = incur_1.Cli.create('formo', {
60
- version: '0.1.0',
61
- description: 'Formo API CLI — Web3 analytics from the terminal',
60
+ const cli = incur_1.Cli.create("formo", {
61
+ version: "0.2.0",
62
+ description: "Formo API CLI — Web3 analytics from the terminal",
62
63
  sync: {
63
64
  suggestions: [
64
- 'get the profile for wallet 0xabc',
65
- 'search profiles with net worth > 10000',
66
- 'run a SQL query on my analytics data',
67
- 'search profiles ordered by last_onchain desc',
68
- 'list all project alerts',
69
- 'create an alert for high-value transactions',
70
- 'list all dashboard boards',
71
- 'list charts in a board',
72
- 'list all tracked contracts',
73
- 'register a new smart contract',
74
- 'list user segments',
75
- 'import wallet addresses',
65
+ "get the profile for wallet 0xabc",
66
+ "search profiles with net worth > 10000",
67
+ "run a SQL query on my analytics data",
68
+ "show traffic KPIs for the last 7 days",
69
+ "get the conversion funnel for the last month",
70
+ "list the top wallets by activity",
71
+ "search profiles ordered by last_onchain desc",
72
+ "list all project alerts",
73
+ "create an alert for high-value transactions",
74
+ "list charts in a board",
75
+ "list all tracked contracts",
76
+ "register a new smart contract",
77
+ "list user segments",
78
+ "import wallet addresses",
76
79
  ],
77
80
  },
78
81
  });
79
82
  // ── login ──
80
- cli.command('login', {
81
- description: 'Authenticate with your Formo API key',
83
+ cli.command("login", {
84
+ description: "Authenticate with your Formo API key",
82
85
  args: incur_1.z.object({
83
- apiKey: incur_1.z.string().optional().describe('Your formo_ API key'),
86
+ apiKey: incur_1.z.string().optional().describe("Your formo_ API key"),
84
87
  }),
85
88
  options: incur_1.z.object({}),
86
- examples: [
87
- { args: { apiKey: 'formo_abc123' }, description: 'Save API key' },
88
- ],
89
+ examples: [{ args: { apiKey: "formo_abc123" }, description: "Save API key" }],
89
90
  async run({ args }) {
90
91
  // No key provided → show guide
91
92
  if (!args.apiKey) {
92
93
  if (process.stdout.isTTY) {
93
94
  process.stderr.write(loginGuide());
94
95
  }
95
- return { ok: false, message: 'No API key provided. See guide above.' };
96
+ return { ok: false, message: "No API key provided. See guide above." };
96
97
  }
97
98
  const isTTY = process.stdout.isTTY;
98
99
  // Validate the key against the API
99
100
  if (isTTY) {
100
- process.stderr.write('\n' + ui_1.color.dim('Validating API key…') + '\n');
101
+ process.stderr.write("\n" + ui_1.color.dim("Validating API key…") + "\n");
101
102
  }
102
103
  const workspaceInfo = await validateAndFetchWorkspace(args.apiKey);
103
104
  // Save key + workspace info (save even if validation fails — user might be offline)
104
105
  // Only include workspace/projectId when present so offline logins don't wipe existing values
105
106
  (0, config_1.saveConfig)({
106
107
  apiKey: args.apiKey,
107
- ...(workspaceInfo?.workspace !== undefined && { workspace: workspaceInfo.workspace }),
108
- ...(workspaceInfo?.projectId !== undefined && { projectId: workspaceInfo.projectId }),
108
+ ...(workspaceInfo?.workspace !== undefined && {
109
+ workspace: workspaceInfo.workspace,
110
+ }),
111
+ ...(workspaceInfo?.projectId !== undefined && {
112
+ projectId: workspaceInfo.projectId,
113
+ }),
109
114
  });
110
115
  // Show feedback in human mode
111
116
  if (isTTY) {
112
117
  const masked = args.apiKey.length > 12
113
- ? args.apiKey.slice(0, 8) + '…' + args.apiKey.slice(-4)
114
- : '***';
118
+ ? args.apiKey.slice(0, 8) + "…" + args.apiKey.slice(-4)
119
+ : "***";
115
120
  if (workspaceInfo) {
116
- process.stderr.write((0, ui_1.success)('API key validated and saved!') +
117
- '\n' +
121
+ process.stderr.write((0, ui_1.success)("API key validated and saved!") +
122
+ "\n" +
118
123
  (0, ui_1.info)(`Key: ${ui_1.color.dim(masked)}`) +
119
- '\n' +
124
+ "\n" +
120
125
  (0, ui_1.info)(`Workspace: ${ui_1.color.bold(workspaceInfo.workspace)}`) +
121
- '\n' +
126
+ "\n" +
122
127
  (workspaceInfo.projectId
123
- ? (0, ui_1.info)(`Project: ${ui_1.color.dim(workspaceInfo.projectId)}`) + '\n'
124
- : '') +
125
- (0, ui_1.info)(`Config: ${ui_1.color.dim('~/.config/formo/config.json')}`) +
126
- '\n\n' +
127
- ui_1.color.dim('You can now use all formo commands.') +
128
- '\n' +
129
- ui_1.color.dim('Run ') +
130
- ui_1.color.green('formo --help') +
131
- ui_1.color.dim(' to see available commands.') +
132
- '\n\n');
128
+ ? (0, ui_1.info)(`Project: ${ui_1.color.dim(workspaceInfo.projectId)}`) + "\n"
129
+ : "") +
130
+ (0, ui_1.info)(`Config: ${ui_1.color.dim("~/.config/formo/config.json")}`) +
131
+ "\n\n" +
132
+ ui_1.color.dim("You can now use all formo commands.") +
133
+ "\n" +
134
+ ui_1.color.dim("Run ") +
135
+ ui_1.color.green("formo --help") +
136
+ ui_1.color.dim(" to see available commands.") +
137
+ "\n\n");
133
138
  }
134
139
  else {
135
- process.stderr.write((0, ui_1.warn)('API key saved but could not be validated.') +
136
- '\n' +
140
+ process.stderr.write((0, ui_1.warn)("API key saved but could not be validated.") +
141
+ "\n" +
137
142
  (0, ui_1.info)(`Key: ${ui_1.color.dim(masked)}`) +
138
- '\n' +
139
- (0, ui_1.info)(`Config: ${ui_1.color.dim('~/.config/formo/config.json')}`) +
140
- '\n' +
141
- ui_1.color.dim('The API might be unreachable. Your key is saved and will be used for requests.') +
142
- '\n\n');
143
+ "\n" +
144
+ (0, ui_1.info)(`Config: ${ui_1.color.dim("~/.config/formo/config.json")}`) +
145
+ "\n" +
146
+ ui_1.color.dim("The API might be unreachable. Your key is saved and will be used for requests.") +
147
+ "\n\n");
143
148
  }
144
149
  }
145
150
  return {
146
151
  ok: true,
147
152
  message: workspaceInfo
148
- ? 'API key validated and saved'
149
- : 'API key saved (not validated)',
153
+ ? "API key validated and saved"
154
+ : "API key saved (not validated)",
150
155
  workspace: workspaceInfo?.workspace ?? null,
151
156
  projectId: workspaceInfo?.projectId ?? null,
152
157
  };
153
158
  },
154
159
  });
155
160
  // ── logout ──
156
- cli.command('logout', {
157
- description: 'Remove saved API key and clear authentication',
161
+ cli.command("logout", {
162
+ description: "Remove saved API key and clear authentication",
158
163
  run() {
159
164
  const hadKey = !!(0, config_1.readConfig)().apiKey;
160
165
  (0, config_1.clearConfig)();
161
166
  if (process.stdout.isTTY) {
162
- process.stderr.write('\n');
167
+ process.stderr.write("\n");
163
168
  if (hadKey) {
164
- process.stderr.write((0, ui_1.success)('Logged out successfully.') +
165
- '\n' +
166
- (0, ui_1.info)('API key removed from ~/.config/formo/config.json') +
167
- '\n');
169
+ process.stderr.write((0, ui_1.success)("Logged out successfully.") +
170
+ "\n" +
171
+ (0, ui_1.info)("API key removed from ~/.config/formo/config.json") +
172
+ "\n");
168
173
  }
169
174
  else {
170
- process.stderr.write((0, ui_1.info)('No saved API key found — already logged out.') + '\n');
175
+ process.stderr.write((0, ui_1.info)("No saved API key found — already logged out.") + "\n");
171
176
  }
172
177
  if (process.env.FORMO_API_KEY) {
173
- process.stderr.write('\n' +
174
- (0, ui_1.warn)(`${ui_1.color.yellow('FORMO_API_KEY')} env var is still set.`) +
175
- '\n' +
176
- (0, ui_1.info)(`Run ${ui_1.color.green('unset FORMO_API_KEY')} to fully log out.`) +
177
- '\n');
178
+ process.stderr.write("\n" +
179
+ (0, ui_1.warn)(`${ui_1.color.yellow("FORMO_API_KEY")} env var is still set.`) +
180
+ "\n" +
181
+ (0, ui_1.info)(`Run ${ui_1.color.green("unset FORMO_API_KEY")} to fully log out.`) +
182
+ "\n");
178
183
  }
179
- process.stderr.write('\n');
184
+ process.stderr.write("\n");
180
185
  }
181
186
  return {
182
187
  ok: true,
183
- message: hadKey ? 'API key removed' : 'Already logged out',
188
+ message: hadKey ? "API key removed" : "Already logged out",
184
189
  envVarSet: !!process.env.FORMO_API_KEY,
185
190
  };
186
191
  },
187
192
  });
188
193
  // ── status ──
189
- cli.command('status', {
190
- description: 'Show current authentication and CLI status',
194
+ cli.command("status", {
195
+ description: "Show current authentication and CLI status",
191
196
  run() {
192
197
  const apiKey = (0, config_1.getApiKey)();
193
198
  const config = (0, config_1.readConfig)();
194
199
  const source = process.env.FORMO_API_KEY
195
- ? 'FORMO_API_KEY env var'
196
- : 'config file';
200
+ ? "FORMO_API_KEY env var"
201
+ : "config file";
197
202
  if (process.stdout.isTTY) {
198
- process.stderr.write('\n');
203
+ process.stderr.write("\n");
199
204
  if (apiKey) {
200
205
  const masked = apiKey.length > 12
201
- ? apiKey.slice(0, 8) + '…' + apiKey.slice(-4)
202
- : '***';
203
- process.stderr.write((0, ui_1.success)('Authenticated') +
204
- '\n' +
206
+ ? apiKey.slice(0, 8) + "…" + apiKey.slice(-4)
207
+ : "***";
208
+ process.stderr.write((0, ui_1.success)("Authenticated") +
209
+ "\n" +
205
210
  (0, ui_1.info)(`Key: ${ui_1.color.dim(masked)}`) +
206
- '\n' +
211
+ "\n" +
207
212
  (0, ui_1.info)(`Source: ${ui_1.color.dim(source)}`) +
208
- '\n' +
213
+ "\n" +
209
214
  (config.workspace
210
- ? (0, ui_1.info)(`Workspace: ${ui_1.color.bold(config.workspace)}`) + '\n'
211
- : '') +
215
+ ? (0, ui_1.info)(`Workspace: ${ui_1.color.bold(config.workspace)}`) + "\n"
216
+ : "") +
212
217
  (config.projectId
213
- ? (0, ui_1.info)(`Project ID: ${ui_1.color.dim(config.projectId)}`) + '\n'
214
- : '') +
215
- '\n');
218
+ ? (0, ui_1.info)(`Project ID: ${ui_1.color.dim(config.projectId)}`) + "\n"
219
+ : "") +
220
+ "\n");
216
221
  }
217
222
  else {
218
- process.stderr.write((0, ui_1.error)('Not authenticated') +
219
- '\n\n' +
220
- (0, ui_1.info)(`Run ${ui_1.color.green('formo login')} to get started.`) +
221
- '\n\n');
223
+ process.stderr.write((0, ui_1.error)("Not authenticated") +
224
+ "\n\n" +
225
+ (0, ui_1.info)(`Run ${ui_1.color.green("formo login")} to get started.`) +
226
+ "\n\n");
222
227
  }
223
228
  }
224
229
  return {
@@ -226,34 +231,14 @@ cli.command('status', {
226
231
  source: apiKey ? source : null,
227
232
  workspace: config.workspace ?? null,
228
233
  projectId: config.projectId ?? null,
229
- configFile: config.apiKey ? '~/.config/formo/config.json' : null,
234
+ configFile: config.apiKey ? "~/.config/formo/config.json" : null,
230
235
  };
231
236
  },
232
237
  });
233
- // ── query ──
234
- cli.command('query', {
235
- description: 'Run a SQL query against your Formo analytics data',
236
- args: incur_1.z.object({
237
- sql: incur_1.z.string().describe('SQL query string to execute'),
238
- }),
239
- options: incur_1.z.object({}),
240
- examples: [
241
- {
242
- args: { sql: 'SELECT count(*) FROM events' },
243
- description: 'Count all events',
244
- },
245
- {
246
- args: { sql: 'SELECT address, net_worth_usd FROM wallet_profiles ORDER BY net_worth_usd DESC LIMIT 10' },
247
- description: 'Top 10 wallets by net worth',
248
- },
249
- ],
250
- hint: 'Requires query:read scope on your API key.',
251
- run({ args }) {
252
- return (0, query_1.queryRunRun)(args.sql);
253
- },
254
- });
255
238
  // ── command groups ──
256
239
  cli.command(profiles_1.profiles);
240
+ cli.command(query_1.query);
241
+ cli.command(analytics_1.analytics);
257
242
  cli.command(alerts_1.alerts);
258
243
  cli.command(boards_1.boards);
259
244
  cli.command(charts_1.charts);
@@ -262,7 +247,7 @@ cli.command(segments_1.segments);
262
247
  cli.command(import_1.importCmd);
263
248
  // Show banner when run with no args (root help)
264
249
  const args = process.argv.slice(2);
265
- const isRootHelp = args.length === 0 || (args.length === 1 && args[0] === '--help');
250
+ const isRootHelp = args.length === 0 || (args.length === 1 && args[0] === "--help");
266
251
  if (isRootHelp && process.stdout.isTTY) {
267
252
  process.stderr.write((0, ui_1.banner)());
268
253
  }
@@ -1,3 +1,29 @@
1
+ import { AxiosError } from 'axios';
2
+ export interface ApiErrorBody {
3
+ error?: {
4
+ code?: string;
5
+ message?: string;
6
+ doc_url?: string;
7
+ param?: string;
8
+ details?: Record<string, unknown>;
9
+ };
10
+ }
11
+ export interface DecoratedApiError extends Error {
12
+ status?: number;
13
+ code?: string;
14
+ docUrl?: string;
15
+ param?: string;
16
+ details?: Record<string, unknown>;
17
+ transportCode?: string;
18
+ }
19
+ /**
20
+ * Translate an AxiosError into a thrown Error with the API's structured
21
+ * `{ error: { code, message, doc_url, param, details } }` envelope decoded
22
+ * onto the Error instance and into a multi-line, human-readable `.message`.
23
+ *
24
+ * Exported for unit testing — used by the response interceptor below.
25
+ */
26
+ export declare function parseApiError(error: AxiosError): DecoratedApiError;
1
27
  declare function createClient(): import("axios").AxiosInstance;
2
28
  export declare function requireApiKey(): void;
3
29
  export { createClient };
@@ -3,11 +3,40 @@ 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.parseApiError = parseApiError;
6
7
  exports.requireApiKey = requireApiKey;
7
8
  exports.createClient = createClient;
8
9
  const axios_1 = __importDefault(require("axios"));
9
10
  const config_1 = require("./config");
10
11
  const BASE_URL = 'https://api.formo.so';
12
+ /**
13
+ * Translate an AxiosError into a thrown Error with the API's structured
14
+ * `{ error: { code, message, doc_url, param, details } }` envelope decoded
15
+ * onto the Error instance and into a multi-line, human-readable `.message`.
16
+ *
17
+ * Exported for unit testing — used by the response interceptor below.
18
+ */
19
+ function parseApiError(error) {
20
+ const status = error.response?.status;
21
+ const body = error.response?.data;
22
+ const apiError = body?.error;
23
+ const baseMessage = apiError?.message ?? error.message;
24
+ const parts = [];
25
+ parts.push(apiError?.code ? `[${apiError.code}] ${baseMessage}` : baseMessage);
26
+ if (apiError?.param)
27
+ parts.push(`Param: ${apiError.param}`);
28
+ if (apiError?.doc_url)
29
+ parts.push(`Docs: ${apiError.doc_url}`);
30
+ const message = parts.join('\n ');
31
+ return Object.assign(new Error(message), {
32
+ status,
33
+ code: apiError?.code,
34
+ docUrl: apiError?.doc_url,
35
+ param: apiError?.param,
36
+ details: apiError?.details,
37
+ transportCode: error.code,
38
+ });
39
+ }
11
40
  function createClient() {
12
41
  const apiKey = (0, config_1.getApiKey)();
13
42
  const baseURL = BASE_URL;
@@ -20,13 +49,7 @@ function createClient() {
20
49
  },
21
50
  });
22
51
  instance.interceptors.response.use((res) => res.data, (error) => {
23
- const status = error.response?.status;
24
- const data = error.response?.data;
25
- const errorField = data?.error;
26
- const message = data?.message ??
27
- (typeof errorField === 'object' ? errorField?.message : errorField) ??
28
- error.message;
29
- throw Object.assign(new Error(message), { status, code: error.code });
52
+ throw parseApiError(error);
30
53
  });
31
54
  return instance;
32
55
  }
package/package.json CHANGED
@@ -1,7 +1,16 @@
1
1
  {
2
2
  "name": "@formo/cli",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
+ "packageManager": "pnpm@11.1.2",
4
5
  "description": "Formo API CLI — query profiles and analytics data",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/getformo/cli.git"
9
+ },
10
+ "homepage": "https://github.com/getformo/cli#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/getformo/cli/issues"
13
+ },
5
14
  "bin": {
6
15
  "formo": "dist/index.js"
7
16
  },
@@ -20,7 +29,7 @@
20
29
  "test:watch": "mocha --watch"
21
30
  },
22
31
  "dependencies": {
23
- "axios": "^1.7.0",
32
+ "axios": "^1.15.2",
24
33
  "incur": "^0.3.4"
25
34
  },
26
35
  "overrides": {