@formo/cli 0.2.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +154 -40
- package/dist/commands/alerts.d.ts +16 -3
- package/dist/commands/alerts.js +94 -24
- package/dist/commands/analytics.d.ts +25 -0
- package/dist/commands/analytics.js +153 -0
- package/dist/commands/boards.d.ts +11 -2
- package/dist/commands/boards.js +49 -17
- package/dist/commands/charts.d.ts +30 -3
- package/dist/commands/charts.js +297 -33
- package/dist/commands/contracts.d.ts +17 -13
- package/dist/commands/contracts.js +165 -53
- package/dist/commands/events.d.ts +9 -0
- package/dist/commands/events.js +64 -0
- package/dist/commands/import.d.ts +8 -4
- package/dist/commands/import.js +33 -11
- package/dist/commands/profiles.d.ts +35 -2
- package/dist/commands/profiles.js +260 -32
- package/dist/commands/query.js +5 -1
- package/dist/commands/segments.d.ts +6 -2
- package/dist/commands/segments.js +20 -6
- package/dist/index.js +15 -3
- package/dist/lib/client.d.ts +10 -1
- package/dist/lib/client.js +27 -4
- package/dist/lib/config.js +10 -0
- package/dist/lib/json.d.ts +5 -0
- package/dist/lib/json.js +54 -0
- package/dist/lib/sql.d.ts +31 -0
- package/dist/lib/sql.js +151 -0
- package/package.json +3 -3
package/dist/lib/client.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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.');
|
package/dist/lib/config.js
CHANGED
|
@@ -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[];
|
package/dist/lib/json.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL helpers for the query command.
|
|
3
|
+
*
|
|
4
|
+
* The Formo query API wraps the SQL you submit so it can paginate the result
|
|
5
|
+
* set and force a machine-readable response:
|
|
6
|
+
*
|
|
7
|
+
* SELECT * FROM (<your query>) LIMIT <n> FORMAT JSON
|
|
8
|
+
*
|
|
9
|
+
* ClickHouse does not allow a `FORMAT` clause inside a subquery, so if your
|
|
10
|
+
* query ends in its own `FORMAT` clause the wrapped statement becomes:
|
|
11
|
+
*
|
|
12
|
+
* SELECT * FROM (SELECT ... FORMAT CSV) LIMIT 100 FORMAT JSON
|
|
13
|
+
*
|
|
14
|
+
* which ClickHouse rejects with a 400. A trailing `FORMAT` (or a trailing
|
|
15
|
+
* semicolon) can never take effect through this endpoint anyway — the outer
|
|
16
|
+
* `FORMAT JSON` always wins, and output shaping is the CLI's `--format` job —
|
|
17
|
+
* so we remove it before sending and let the server wrap a clean query.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Strip a trailing, top-level `FORMAT <name>` clause and any trailing
|
|
21
|
+
* semicolons from a SQL statement.
|
|
22
|
+
*
|
|
23
|
+
* The scan is aware of string literals, quoted identifiers, and comments, so
|
|
24
|
+
* `FORMAT`-looking text inside them is never mistaken for a real clause. The
|
|
25
|
+
* match is anchored to the end of the statement, so a `FORMAT` nested inside
|
|
26
|
+
* parentheses (a subquery) or part of an identifier/function such as
|
|
27
|
+
* `formatDateTime(...)` — or a column aliased `format` — is left untouched.
|
|
28
|
+
*
|
|
29
|
+
* Returns the original input unchanged when there is nothing to strip.
|
|
30
|
+
*/
|
|
31
|
+
export declare function stripTrailingFormatClause(sql: string): string;
|
package/dist/lib/sql.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* SQL helpers for the query command.
|
|
4
|
+
*
|
|
5
|
+
* The Formo query API wraps the SQL you submit so it can paginate the result
|
|
6
|
+
* set and force a machine-readable response:
|
|
7
|
+
*
|
|
8
|
+
* SELECT * FROM (<your query>) LIMIT <n> FORMAT JSON
|
|
9
|
+
*
|
|
10
|
+
* ClickHouse does not allow a `FORMAT` clause inside a subquery, so if your
|
|
11
|
+
* query ends in its own `FORMAT` clause the wrapped statement becomes:
|
|
12
|
+
*
|
|
13
|
+
* SELECT * FROM (SELECT ... FORMAT CSV) LIMIT 100 FORMAT JSON
|
|
14
|
+
*
|
|
15
|
+
* which ClickHouse rejects with a 400. A trailing `FORMAT` (or a trailing
|
|
16
|
+
* semicolon) can never take effect through this endpoint anyway — the outer
|
|
17
|
+
* `FORMAT JSON` always wins, and output shaping is the CLI's `--format` job —
|
|
18
|
+
* so we remove it before sending and let the server wrap a clean query.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.stripTrailingFormatClause = stripTrailingFormatClause;
|
|
22
|
+
/**
|
|
23
|
+
* Strip a trailing, top-level `FORMAT <name>` clause and any trailing
|
|
24
|
+
* semicolons from a SQL statement.
|
|
25
|
+
*
|
|
26
|
+
* The scan is aware of string literals, quoted identifiers, and comments, so
|
|
27
|
+
* `FORMAT`-looking text inside them is never mistaken for a real clause. The
|
|
28
|
+
* match is anchored to the end of the statement, so a `FORMAT` nested inside
|
|
29
|
+
* parentheses (a subquery) or part of an identifier/function such as
|
|
30
|
+
* `formatDateTime(...)` — or a column aliased `format` — is left untouched.
|
|
31
|
+
*
|
|
32
|
+
* Returns the original input unchanged when there is nothing to strip.
|
|
33
|
+
*/
|
|
34
|
+
function stripTrailingFormatClause(sql) {
|
|
35
|
+
if (!sql)
|
|
36
|
+
return sql;
|
|
37
|
+
const masked = maskLiterals(sql);
|
|
38
|
+
// A genuine trailing FORMAT clause: the keyword `format` preceded by a word
|
|
39
|
+
// boundary (so `formatDateTime` or a column aliased `format` is safe),
|
|
40
|
+
// followed by exactly one identifier (the format name) and nothing else.
|
|
41
|
+
const formatClause = /(^|[^A-Za-z0-9_])format\s+[A-Za-z_][A-Za-z0-9_]*\s*$/i;
|
|
42
|
+
let end = sql.length;
|
|
43
|
+
let didStrip = false;
|
|
44
|
+
// Peel top-level semicolons and a trailing FORMAT clause repeatedly so any
|
|
45
|
+
// ordering collapses to the bare query, e.g. `... FORMAT CSV;`,
|
|
46
|
+
// `...; FORMAT CSV`, or `... FORMAT CSV ;`. Trailing whitespace and comments
|
|
47
|
+
// are only ever skipped to *look* past them — never removed on their own.
|
|
48
|
+
for (;;) {
|
|
49
|
+
let e = end;
|
|
50
|
+
while (e > 0 && /\s/.test(masked[e - 1]))
|
|
51
|
+
e--;
|
|
52
|
+
if (e === 0)
|
|
53
|
+
break;
|
|
54
|
+
if (masked[e - 1] === ';') {
|
|
55
|
+
end = e - 1;
|
|
56
|
+
didStrip = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const match = formatClause.exec(masked.slice(0, e));
|
|
60
|
+
if (match) {
|
|
61
|
+
// Cut at the `format` keyword, after the leading word-boundary char.
|
|
62
|
+
end = match.index + match[1].length;
|
|
63
|
+
didStrip = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
if (!didStrip)
|
|
69
|
+
return sql;
|
|
70
|
+
// Tidy the real whitespace now left dangling where the clause used to be.
|
|
71
|
+
// Comments are not whitespace, so any genuine comment is preserved.
|
|
72
|
+
const stripped = sql.slice(0, end).replace(/\s+$/, '');
|
|
73
|
+
// Never manufacture an empty query from non-empty input (degenerate inputs
|
|
74
|
+
// such as a bare `FORMAT JSON`): let the original surface its own error.
|
|
75
|
+
return stripped === '' ? sql : stripped;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Return a copy of `sql` with the *contents* of string literals, quoted
|
|
79
|
+
* identifiers, and comments replaced by spaces, preserving the original length
|
|
80
|
+
* so indices stay aligned with the source. Quote delimiters are kept; comments
|
|
81
|
+
* are blanked entirely. This lets the clause scanner reason about real SQL code
|
|
82
|
+
* without tripping over keywords that merely appear inside literals or comments.
|
|
83
|
+
*/
|
|
84
|
+
function maskLiterals(sql) {
|
|
85
|
+
const out = [];
|
|
86
|
+
const n = sql.length;
|
|
87
|
+
let i = 0;
|
|
88
|
+
while (i < n) {
|
|
89
|
+
const c = sql[i];
|
|
90
|
+
const next = i + 1 < n ? sql[i + 1] : '';
|
|
91
|
+
// Line comment: -- ... or # ... to end of line. ClickHouse accepts `#`
|
|
92
|
+
// and `#!` line comments for MySQL compatibility.
|
|
93
|
+
if ((c === '-' && next === '-') || c === '#') {
|
|
94
|
+
while (i < n && sql[i] !== '\n') {
|
|
95
|
+
out.push(' ');
|
|
96
|
+
i++;
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// Block comment: /* ... */
|
|
101
|
+
if (c === '/' && next === '*') {
|
|
102
|
+
out.push(' ', ' ');
|
|
103
|
+
i += 2;
|
|
104
|
+
while (i < n && !(sql[i] === '*' && sql[i + 1] === '/')) {
|
|
105
|
+
out.push(' ');
|
|
106
|
+
i++;
|
|
107
|
+
}
|
|
108
|
+
if (i < n) {
|
|
109
|
+
out.push(' ', ' ');
|
|
110
|
+
i += 2;
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// String literal or quoted identifier: '...', "...", `...`
|
|
115
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
116
|
+
const quote = c;
|
|
117
|
+
out.push(quote);
|
|
118
|
+
i++;
|
|
119
|
+
while (i < n) {
|
|
120
|
+
const d = sql[i];
|
|
121
|
+
// Backslash escapes are honored inside single-quoted strings.
|
|
122
|
+
if (d === '\\' && quote === "'") {
|
|
123
|
+
out.push(' ');
|
|
124
|
+
i++;
|
|
125
|
+
if (i < n) {
|
|
126
|
+
out.push(' ');
|
|
127
|
+
i++;
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (d === quote) {
|
|
132
|
+
// A doubled delimiter is an escaped quote, not the terminator.
|
|
133
|
+
if (i + 1 < n && sql[i + 1] === quote) {
|
|
134
|
+
out.push(' ', ' ');
|
|
135
|
+
i += 2;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
out.push(quote);
|
|
139
|
+
i++;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
out.push(' ');
|
|
143
|
+
i++;
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
out.push(c);
|
|
148
|
+
i++;
|
|
149
|
+
}
|
|
150
|
+
return out.join('');
|
|
151
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@formo/cli",
|
|
3
|
-
"version": "0.2
|
|
4
|
-
"packageManager": "pnpm@
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"packageManager": "pnpm@11.1.2",
|
|
5
5
|
"description": "Formo API CLI — query profiles and analytics data",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"test:watch": "mocha --watch"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"axios": "^1.
|
|
32
|
+
"axios": "^1.15.2",
|
|
33
33
|
"incur": "^0.3.4"
|
|
34
34
|
},
|
|
35
35
|
"overrides": {
|