error-ux-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.
- package/index.js +48 -8
- package/lib/commands.js +1247 -730
- package/lib/dashboard.mjs +4 -4
- package/lib/dbExplorer.mjs +284 -0
- package/lib/git.js +91 -4
- package/lib/pgClient.js +102 -0
- package/lib/plugins.js +94 -0
- package/lib/storage.js +71 -3
- package/lib/utils.js +22 -10
- package/package.json +13 -6
package/lib/dashboard.mjs
CHANGED
|
@@ -74,7 +74,7 @@ function LiveStatus() {
|
|
|
74
74
|
return e(Text, {color: 'yellow'}, statuses[tick % statuses.length]);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
function IntroPanel({done}) {
|
|
77
|
+
function IntroPanel({done, name}) {
|
|
78
78
|
return e(
|
|
79
79
|
Box,
|
|
80
80
|
{borderStyle: 'round', width: 68, paddingX: 1, marginBottom: 1},
|
|
@@ -83,8 +83,8 @@ function IntroPanel({done}) {
|
|
|
83
83
|
Box,
|
|
84
84
|
{flexDirection: 'column', justifyContent: 'center'},
|
|
85
85
|
done
|
|
86
|
-
? e(Text, {bold: true, color: 'green'},
|
|
87
|
-
: e(AnimatedText, {text:
|
|
86
|
+
? e(Text, {bold: true, color: 'green'}, `Welcome ${name}`)
|
|
87
|
+
: e(AnimatedText, {text: `Welcome ${name}`, speed: 1}),
|
|
88
88
|
e(Text, {dimColor: true}, 'terminal cockpit online'),
|
|
89
89
|
e(LiveStatus),
|
|
90
90
|
e(LoadingBar, {width: 24})
|
|
@@ -115,7 +115,7 @@ function Dashboard({data}) {
|
|
|
115
115
|
return e(
|
|
116
116
|
Box,
|
|
117
117
|
{flexDirection: 'column'},
|
|
118
|
-
e(IntroPanel, {done: stage > 0}),
|
|
118
|
+
e(IntroPanel, {done: stage > 0, name: data.username}),
|
|
119
119
|
stage >= 1 && e(
|
|
120
120
|
Box,
|
|
121
121
|
{borderStyle: 'double', width: 68, marginBottom: 1},
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo } from 'react';
|
|
2
|
+
import { Box, Text, render, useApp, useInput } from 'ink';
|
|
3
|
+
import pg from './pgClient.js';
|
|
4
|
+
|
|
5
|
+
const e = React.createElement;
|
|
6
|
+
|
|
7
|
+
// --- Components ---
|
|
8
|
+
|
|
9
|
+
function Header({ config, level, context, isQueryMode }) {
|
|
10
|
+
let path = 'DB';
|
|
11
|
+
if (level >= 1) path += ` > ${context.db}`;
|
|
12
|
+
if (level >= 2) path += ` > ${context.schema}`;
|
|
13
|
+
if (level >= 3) path += ` > ${context.table}`;
|
|
14
|
+
if (isQueryMode) path += ' > CUSTOM QUERY';
|
|
15
|
+
|
|
16
|
+
return e(
|
|
17
|
+
Box,
|
|
18
|
+
{ borderStyle: 'double', borderColor: 'magenta', paddingX: 1, marginBottom: 1 },
|
|
19
|
+
e(Text, { color: 'cyan', bold: true }, ' \u26A1 PG GOD-MODE EXPLORER '),
|
|
20
|
+
e(Text, { color: 'white' }, ` [${path}] `)
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function Pane({ title, width, children, active, color = 'cyan' }) {
|
|
25
|
+
return e(
|
|
26
|
+
Box,
|
|
27
|
+
{
|
|
28
|
+
flexDirection: 'column',
|
|
29
|
+
borderStyle: 'double',
|
|
30
|
+
borderColor: active ? 'magenta' : 'gray',
|
|
31
|
+
width,
|
|
32
|
+
height: 20,
|
|
33
|
+
paddingX: 1,
|
|
34
|
+
marginRight: 1
|
|
35
|
+
},
|
|
36
|
+
e(Text, { bold: true, color: active ? 'magenta' : color }, title),
|
|
37
|
+
e(Box, { flexDirection: 'column', marginTop: 1 }, children)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ColumnRow({ column, selected }) {
|
|
42
|
+
const primaryIcon = column.is_primary ? '🔑 ' : ' ';
|
|
43
|
+
return e(
|
|
44
|
+
Box,
|
|
45
|
+
null,
|
|
46
|
+
e(Text, { color: selected ? 'magenta' : 'white' }, primaryIcon),
|
|
47
|
+
e(Box, { width: 15 }, e(Text, { color: selected ? 'magenta' : 'white' }, column.column_name)),
|
|
48
|
+
e(Box, { width: 12 }, e(Text, { dimColor: true }, column.data_type)),
|
|
49
|
+
e(Text, { dimColor: true }, column.is_nullable === 'YES' ? 'null' : 'not null')
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --- Main Explorer ---
|
|
54
|
+
|
|
55
|
+
function Explorer({ credentials }) {
|
|
56
|
+
const { exit } = useApp();
|
|
57
|
+
const [level, setLevel] = useState(0); // 0: DB, 1: Schema, 2: Table
|
|
58
|
+
const [context, setContext] = useState({ db: '', schema: '', table: '' });
|
|
59
|
+
const [items, setItems] = useState([]);
|
|
60
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
61
|
+
const [search, setSearch] = useState('');
|
|
62
|
+
const [metadata, setMetadata] = useState([]);
|
|
63
|
+
const [preview, setPreview] = useState([]);
|
|
64
|
+
const [error, setError] = useState(null);
|
|
65
|
+
const [isQueryMode, setIsQueryMode] = useState(false);
|
|
66
|
+
const [customQuery, setCustomQuery] = useState('');
|
|
67
|
+
const [queryResult, setQueryResult] = useState(null);
|
|
68
|
+
|
|
69
|
+
// Filtered items based on search
|
|
70
|
+
const filteredItems = useMemo(() => {
|
|
71
|
+
if (!search) return items;
|
|
72
|
+
return items.filter(item => item.toLowerCase().includes(search.toLowerCase()));
|
|
73
|
+
}, [items, search]);
|
|
74
|
+
|
|
75
|
+
// Fetch Level Data
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (isQueryMode) return;
|
|
78
|
+
const fetchData = async () => {
|
|
79
|
+
try {
|
|
80
|
+
setError(null);
|
|
81
|
+
setSearch('');
|
|
82
|
+
setSelectedIndex(0);
|
|
83
|
+
|
|
84
|
+
if (level === 0) {
|
|
85
|
+
const dbs = await pg.listDatabases(credentials);
|
|
86
|
+
setItems(dbs);
|
|
87
|
+
} else if (level === 1) {
|
|
88
|
+
const schemas = await pg.listSchemas({ ...credentials, database: context.db });
|
|
89
|
+
setItems(schemas);
|
|
90
|
+
} else if (level === 2) {
|
|
91
|
+
const tables = await pg.listTables({ ...credentials, database: context.db }, context.schema);
|
|
92
|
+
setItems(tables);
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
setError(err.message);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
fetchData();
|
|
99
|
+
}, [level, context.db, context.schema, credentials, isQueryMode]);
|
|
100
|
+
|
|
101
|
+
// Fetch Table Metadata/Preview
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (isQueryMode) return;
|
|
104
|
+
if (level === 2 && filteredItems[selectedIndex]) {
|
|
105
|
+
const tableName = filteredItems[selectedIndex];
|
|
106
|
+
const fetchTableDetails = async () => {
|
|
107
|
+
try {
|
|
108
|
+
const meta = await pg.getTableMetadata({ ...credentials, database: context.db }, context.schema, tableName);
|
|
109
|
+
setMetadata(meta);
|
|
110
|
+
const data = await pg.getTablePreview({ ...credentials, database: context.db }, context.schema, tableName);
|
|
111
|
+
setPreview(data);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
setMetadata([]);
|
|
114
|
+
setPreview([]);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
fetchTableDetails();
|
|
118
|
+
} else {
|
|
119
|
+
setMetadata([]);
|
|
120
|
+
setPreview([]);
|
|
121
|
+
}
|
|
122
|
+
}, [level, selectedIndex, filteredItems, context.db, context.schema, credentials, isQueryMode]);
|
|
123
|
+
|
|
124
|
+
// Keyboard Input
|
|
125
|
+
useInput((input, key) => {
|
|
126
|
+
if (isQueryMode) {
|
|
127
|
+
if (key.escape) {
|
|
128
|
+
setIsQueryMode(false);
|
|
129
|
+
setCustomQuery('');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (key.return) {
|
|
133
|
+
const runQuery = async () => {
|
|
134
|
+
try {
|
|
135
|
+
const res = await pg.manualQuery({ ...credentials, database: context.db || 'postgres' }, customQuery);
|
|
136
|
+
setQueryResult(res);
|
|
137
|
+
setError(null);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
setError(err.message);
|
|
140
|
+
setQueryResult(null);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
runQuery();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (key.backspace) {
|
|
147
|
+
setCustomQuery(prev => prev.slice(0, -1));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (input) {
|
|
151
|
+
setCustomQuery(prev => prev + input);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (key.escape) {
|
|
158
|
+
if (level > 0) setLevel(level - 1);
|
|
159
|
+
else exit();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (key.upArrow) setSelectedIndex(prev => Math.max(0, prev - 1));
|
|
163
|
+
if (key.downArrow) setSelectedIndex(prev => Math.min(filteredItems.length - 1, prev + 1));
|
|
164
|
+
|
|
165
|
+
if (key.return) {
|
|
166
|
+
const selected = filteredItems[selectedIndex];
|
|
167
|
+
if (!selected) return;
|
|
168
|
+
|
|
169
|
+
if (level === 0) {
|
|
170
|
+
setContext(prev => ({ ...prev, db: selected }));
|
|
171
|
+
setLevel(1);
|
|
172
|
+
} else if (level === 1) {
|
|
173
|
+
setContext(prev => ({ ...prev, schema: selected }));
|
|
174
|
+
setLevel(2);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Toggle Query Mode
|
|
179
|
+
if (input.toLowerCase() === 's') {
|
|
180
|
+
setIsQueryMode(true);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!key.ctrl && !key.meta && input.length === 1 && /^[a-z0-9_]$/i.test(input)) {
|
|
185
|
+
setSearch(prev => prev + input);
|
|
186
|
+
setSelectedIndex(0);
|
|
187
|
+
}
|
|
188
|
+
if (key.backspace) {
|
|
189
|
+
setSearch(prev => prev.slice(0, -1));
|
|
190
|
+
setSelectedIndex(0);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const listTitle = level === 0 ? 'Databases' : level === 1 ? 'Schemas' : 'Tables';
|
|
195
|
+
|
|
196
|
+
return e(
|
|
197
|
+
Box,
|
|
198
|
+
{ flexDirection: 'column' },
|
|
199
|
+
e(Header, { config: credentials, level, context, isQueryMode }),
|
|
200
|
+
|
|
201
|
+
e(
|
|
202
|
+
Box,
|
|
203
|
+
{ flexDirection: 'row' },
|
|
204
|
+
// Navigator or Query Box
|
|
205
|
+
isQueryMode ? e(
|
|
206
|
+
Pane,
|
|
207
|
+
{ title: '[ SQL CONSOLE ]', width: 80, active: true },
|
|
208
|
+
e(Text, { color: 'yellow' }, 'Query: ' + customQuery + '_'),
|
|
209
|
+
e(Text, { dimColor: true, marginTop: 1 }, 'Press [Enter] to Execute | [Esc] to Exit'),
|
|
210
|
+
queryResult && e(
|
|
211
|
+
Box,
|
|
212
|
+
{ flexDirection: 'column', marginTop: 1 },
|
|
213
|
+
e(Text, { color: 'cyan' }, 'Result (First 5 Rows):'),
|
|
214
|
+
e(Text, null, JSON.stringify(queryResult.slice(0, 5), null, 2).slice(0, 500))
|
|
215
|
+
)
|
|
216
|
+
) : e(
|
|
217
|
+
React.Fragment,
|
|
218
|
+
null,
|
|
219
|
+
e(
|
|
220
|
+
Pane,
|
|
221
|
+
{ title: `[ ${listTitle} ]`, width: 35, active: true },
|
|
222
|
+
search && e(Text, { color: 'yellow' }, `Search: ${search}`),
|
|
223
|
+
(() => {
|
|
224
|
+
const visibleCount = 15;
|
|
225
|
+
const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(visibleCount / 2), Math.max(0, filteredItems.length - visibleCount)));
|
|
226
|
+
const visibleItems = filteredItems.slice(startIndex, startIndex + visibleCount);
|
|
227
|
+
|
|
228
|
+
return [
|
|
229
|
+
startIndex > 0 && e(Text, { dimColor: true, alignSelf: 'center' }, '^ more ^'),
|
|
230
|
+
...visibleItems.map((item, i) => {
|
|
231
|
+
const actualIndex = startIndex + i;
|
|
232
|
+
const isSelected = actualIndex === selectedIndex;
|
|
233
|
+
return e(Text, { key: item, color: isSelected ? 'magenta' : 'white', wrap: 'truncate-end' },
|
|
234
|
+
isSelected ? `\u25B6 ${item}` : ` ${item}`);
|
|
235
|
+
}),
|
|
236
|
+
(startIndex + visibleCount < filteredItems.length) && e(Text, { dimColor: true, alignSelf: 'center' }, 'v more v')
|
|
237
|
+
];
|
|
238
|
+
})(),
|
|
239
|
+
filteredItems.length === 0 && e(Text, { dimColor: true }, ' No entries ')
|
|
240
|
+
),
|
|
241
|
+
|
|
242
|
+
// Schema Detail
|
|
243
|
+
level === 2 && e(
|
|
244
|
+
Pane,
|
|
245
|
+
{ title: '[ Table Schema ]', width: 50, active: false, color: 'cyan' },
|
|
246
|
+
...metadata.slice(0, 15).map((col, i) => e(ColumnRow, { key: i, column: col }))
|
|
247
|
+
),
|
|
248
|
+
|
|
249
|
+
// Help Message
|
|
250
|
+
level < 2 && e(
|
|
251
|
+
Pane,
|
|
252
|
+
{ title: '[ Help ]', width: 50, active: false, color: 'gray' },
|
|
253
|
+
e(Text, null, 'Use \u2191\u2193 to navigate'),
|
|
254
|
+
e(Text, null, 'Press [Enter] to select'),
|
|
255
|
+
e(Text, null, 'Type to filter entities'),
|
|
256
|
+
e(Text, { color: 'cyan' }, 'Press [s] for Custom SQL'),
|
|
257
|
+
e(Text, null, 'Press [Esc] to go back')
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
),
|
|
261
|
+
|
|
262
|
+
// Data Preview
|
|
263
|
+
!isQueryMode && level === 2 && e(
|
|
264
|
+
Box,
|
|
265
|
+
{
|
|
266
|
+
borderStyle: 'double',
|
|
267
|
+
borderColor: 'gray',
|
|
268
|
+
width: 86,
|
|
269
|
+
paddingX: 1,
|
|
270
|
+
marginTop: 1,
|
|
271
|
+
flexDirection: 'column'
|
|
272
|
+
},
|
|
273
|
+
e(Text, { bold: true, color: 'yellow' }, '[ Data Preview (Top 5 Rows) ]'),
|
|
274
|
+
preview.length > 0 ? e(Text, null, JSON.stringify(preview[0]).slice(0, 80) + '...') : e(Text, { dimColor: true }, 'No data or table empty')
|
|
275
|
+
),
|
|
276
|
+
|
|
277
|
+
error && e(Text, { color: 'red', bold: true }, `\n\u26A0 DATABASE ERROR: ${error}`)
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function renderExplorer(credentials) {
|
|
282
|
+
const instance = render(e(Explorer, { credentials }));
|
|
283
|
+
await instance.waitUntilExit();
|
|
284
|
+
}
|
package/lib/git.js
CHANGED
|
@@ -1,6 +1,79 @@
|
|
|
1
1
|
const { execSync } = require('child_process');
|
|
2
2
|
const fs = require('fs/promises');
|
|
3
3
|
const path = require('path');
|
|
4
|
+
const https = require('https');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Syncs vault data with a private GitHub Gist.
|
|
8
|
+
* @param {string} token GitHub PAT (standard user PAT)
|
|
9
|
+
* @param {string} gistId The ID of the Secret Gist
|
|
10
|
+
* @param {string} data Encrypted JSON blob (required for PUSH)
|
|
11
|
+
* @param {string} action 'push' or 'pull'
|
|
12
|
+
* @returns {Promise<string|null>}
|
|
13
|
+
*/
|
|
14
|
+
async function gistSync(token, gistId, data, action = 'pull') {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const options = {
|
|
17
|
+
hostname: 'api.github.com',
|
|
18
|
+
path: `/gists/${gistId}`,
|
|
19
|
+
method: action === 'push' ? 'PATCH' : 'GET',
|
|
20
|
+
headers: {
|
|
21
|
+
'Authorization': `Bearer ${token}`,
|
|
22
|
+
'User-Agent': 'error-ux-cli',
|
|
23
|
+
'Accept': 'application/vnd.github+json',
|
|
24
|
+
'X-GitHub-Api-Version': '2022-11-28'
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
if (action === 'push') {
|
|
29
|
+
options.headers['Content-Type'] = 'application/json';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const req = https.request(options, (res) => {
|
|
33
|
+
let body = '';
|
|
34
|
+
res.on('data', (d) => body += d);
|
|
35
|
+
res.on('end', () => {
|
|
36
|
+
if (res.statusCode >= 400) {
|
|
37
|
+
return reject(new Error(`GitHub Error: ${res.statusCode} - ${body}`));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const result = JSON.parse(body);
|
|
42
|
+
if (action === 'pull') {
|
|
43
|
+
// We assume the data is in a file named 'vault.json' or similar.
|
|
44
|
+
// We'll just take the first file's content.
|
|
45
|
+
const files = result.files || {};
|
|
46
|
+
const fileName = Object.keys(files)[0];
|
|
47
|
+
if (fileName) {
|
|
48
|
+
resolve(files[fileName].content);
|
|
49
|
+
} else {
|
|
50
|
+
resolve(null);
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
resolve(body);
|
|
54
|
+
}
|
|
55
|
+
} catch (e) {
|
|
56
|
+
reject(e);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
req.on('error', (e) => reject(e));
|
|
62
|
+
|
|
63
|
+
if (action === 'push' && data) {
|
|
64
|
+
const payload = JSON.stringify({
|
|
65
|
+
files: {
|
|
66
|
+
'vault.json': {
|
|
67
|
+
content: data
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
req.write(payload);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
req.end();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
4
77
|
|
|
5
78
|
function runGit(command) {
|
|
6
79
|
try {
|
|
@@ -90,8 +163,17 @@ async function getNextBranchVersion(name) {
|
|
|
90
163
|
async function branchExists(name) {
|
|
91
164
|
const branch = sanitizeBranchName(name);
|
|
92
165
|
try {
|
|
166
|
+
// Check local branches and current remote branches
|
|
93
167
|
const output = runGit(`git branch -a --list ${quoteShellArg(branch)} ${quoteShellArg(`remotes/origin/${branch}`)}`);
|
|
94
|
-
|
|
168
|
+
if (Boolean(String(output || '').trim())) return true;
|
|
169
|
+
|
|
170
|
+
// Force a remote check if possible
|
|
171
|
+
try {
|
|
172
|
+
const remoteOutput = runGit(`git ls-remote --heads origin ${quoteShellArg(branch)}`);
|
|
173
|
+
return Boolean(String(remoteOutput || '').trim());
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
95
177
|
} catch (error) {
|
|
96
178
|
return false;
|
|
97
179
|
}
|
|
@@ -190,8 +272,7 @@ async function getPushContext(args, savedRepos, rl) {
|
|
|
190
272
|
}
|
|
191
273
|
|
|
192
274
|
if (!targetRepo || !targetRepo.url) {
|
|
193
|
-
|
|
194
|
-
return null;
|
|
275
|
+
targetRepo = { name: 'origin', url: null };
|
|
195
276
|
}
|
|
196
277
|
|
|
197
278
|
return {
|
|
@@ -210,7 +291,12 @@ async function automatedPush(targetRepo, message, branchBase) {
|
|
|
210
291
|
if (!currentRemote) {
|
|
211
292
|
await setRemoteUrl(targetRepo.url, false);
|
|
212
293
|
} else if (currentRemote !== targetRepo.url) {
|
|
213
|
-
|
|
294
|
+
// If it exists but differs, update it
|
|
295
|
+
try {
|
|
296
|
+
await setRemoteUrl(targetRepo.url, true);
|
|
297
|
+
} catch (e) {
|
|
298
|
+
// If it somehow fails, try to force it or just ignore if it's already there
|
|
299
|
+
}
|
|
214
300
|
}
|
|
215
301
|
|
|
216
302
|
const branch = sanitizeBranchName(branchBase);
|
|
@@ -257,6 +343,7 @@ module.exports = {
|
|
|
257
343
|
getNextBranchVersion,
|
|
258
344
|
getRemoteUrl,
|
|
259
345
|
getRepoRoot,
|
|
346
|
+
gistSync,
|
|
260
347
|
initRepo,
|
|
261
348
|
isGitRepo,
|
|
262
349
|
runGit,
|
package/lib/pgClient.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const { Pool } = require('pg');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a connection pool for a specific database.
|
|
5
|
+
*/
|
|
6
|
+
function createPool(config) {
|
|
7
|
+
return new Pool({
|
|
8
|
+
host: config.host || 'localhost',
|
|
9
|
+
port: config.port || 5432,
|
|
10
|
+
user: config.user,
|
|
11
|
+
password: config.password,
|
|
12
|
+
database: config.database,
|
|
13
|
+
ssl: config.useSsl ? { rejectUnauthorized: false } : false,
|
|
14
|
+
connectionTimeoutMillis: 5000
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runs a query and handles the pool lifecycle for one-off tasks.
|
|
20
|
+
*/
|
|
21
|
+
async function query(config, sql, params = []) {
|
|
22
|
+
const pool = createPool(config);
|
|
23
|
+
try {
|
|
24
|
+
const res = await pool.query(sql, params);
|
|
25
|
+
return res.rows;
|
|
26
|
+
} finally {
|
|
27
|
+
await pool.end();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function testConnection(config) {
|
|
32
|
+
return query(config, 'SELECT 1');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function listDatabases(config) {
|
|
36
|
+
const rows = await query(config, 'SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname ASC');
|
|
37
|
+
return rows.map(r => r.datname);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function listSchemas(config) {
|
|
41
|
+
const rows = await query(config, "SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema', 'pg_catalog') ORDER BY schema_name ASC");
|
|
42
|
+
return rows.map(r => r.schema_name);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function listTables(config, schema = 'public') {
|
|
46
|
+
const rows = await query(config, "SELECT table_name FROM information_schema.tables WHERE table_schema = $1 AND table_type = 'BASE TABLE' ORDER BY table_name ASC", [schema]);
|
|
47
|
+
return rows.map(r => r.table_name);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function getTableMetadata(config, schema, table) {
|
|
51
|
+
const sql = `
|
|
52
|
+
SELECT
|
|
53
|
+
column_name,
|
|
54
|
+
data_type,
|
|
55
|
+
is_nullable,
|
|
56
|
+
column_default,
|
|
57
|
+
(
|
|
58
|
+
SELECT 'YES'
|
|
59
|
+
FROM information_schema.key_column_usage kcu
|
|
60
|
+
JOIN information_schema.table_constraints tc ON kcu.constraint_name = tc.constraint_name
|
|
61
|
+
WHERE kcu.table_name = c.table_name
|
|
62
|
+
AND kcu.column_name = c.column_name
|
|
63
|
+
AND tc.constraint_type = 'PRIMARY KEY'
|
|
64
|
+
) as is_primary
|
|
65
|
+
FROM information_schema.columns c
|
|
66
|
+
WHERE table_schema = $1 AND table_name = $2
|
|
67
|
+
ORDER BY ordinal_position ASC
|
|
68
|
+
`;
|
|
69
|
+
return query(config, sql, [schema, table]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function getTablePreview(config, schema, table, limit = 5) {
|
|
73
|
+
// Note: We use double quotes for schema/table names to handle mixed case/special chars safely
|
|
74
|
+
const sql = `SELECT * FROM "${schema}"."${table}" LIMIT ${limit}`;
|
|
75
|
+
return query(config, sql);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function searchColumns(config, columnName) {
|
|
79
|
+
const sql = `
|
|
80
|
+
SELECT table_schema, table_name
|
|
81
|
+
FROM information_schema.columns
|
|
82
|
+
WHERE column_name ILIKE $1
|
|
83
|
+
AND table_schema NOT IN ('information_schema', 'pg_catalog')
|
|
84
|
+
ORDER BY table_schema, table_name
|
|
85
|
+
`;
|
|
86
|
+
return query(config, `%${columnName}%`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function manualQuery(config, sql) {
|
|
90
|
+
return query(config, sql);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
testConnection,
|
|
95
|
+
listDatabases,
|
|
96
|
+
listSchemas,
|
|
97
|
+
listTables,
|
|
98
|
+
getTableMetadata,
|
|
99
|
+
getTablePreview,
|
|
100
|
+
searchColumns,
|
|
101
|
+
manualQuery
|
|
102
|
+
};
|
package/lib/plugins.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const PLUGIN_DIR = path.join(os.homedir(), '.error-ux', 'plugins');
|
|
6
|
+
|
|
7
|
+
function ensurePluginDir() {
|
|
8
|
+
if (!fs.existsSync(PLUGIN_DIR)) {
|
|
9
|
+
fs.mkdirSync(PLUGIN_DIR, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Scans the plugin directory and loads valid JS plugins.
|
|
15
|
+
* Only called for Admin users.
|
|
16
|
+
*/
|
|
17
|
+
function loadPlugins() {
|
|
18
|
+
ensurePluginDir();
|
|
19
|
+
const plugins = {};
|
|
20
|
+
const files = fs.readdirSync(PLUGIN_DIR);
|
|
21
|
+
|
|
22
|
+
for (const file of files) {
|
|
23
|
+
if (file.endsWith('.js')) {
|
|
24
|
+
try {
|
|
25
|
+
const pluginPath = path.join(PLUGIN_DIR, file);
|
|
26
|
+
// Clear cache to allow reloading if needed
|
|
27
|
+
delete require.cache[require.resolve(pluginPath)];
|
|
28
|
+
const plugin = require(pluginPath);
|
|
29
|
+
|
|
30
|
+
if (plugin.name && typeof plugin.run === 'function') {
|
|
31
|
+
plugins[plugin.name.toLowerCase()] = {
|
|
32
|
+
name: plugin.name,
|
|
33
|
+
description: plugin.description || 'No description provided.',
|
|
34
|
+
run: plugin.run
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error(`Failed to load plugin "${file}": ${err.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return plugins;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns an object containing the source code of all .js plugins.
|
|
47
|
+
* Used for Cloud Sync.
|
|
48
|
+
*/
|
|
49
|
+
function getAllPluginSource() {
|
|
50
|
+
ensurePluginDir();
|
|
51
|
+
const source = {};
|
|
52
|
+
const files = fs.readdirSync(PLUGIN_DIR);
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
if (file.endsWith('.js')) {
|
|
55
|
+
const content = fs.readFileSync(path.join(PLUGIN_DIR, file), 'utf8');
|
|
56
|
+
source[file] = content;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return source;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Saves or updates a plugin file.
|
|
64
|
+
*/
|
|
65
|
+
function savePlugin(filename, content) {
|
|
66
|
+
ensurePluginDir();
|
|
67
|
+
const target = path.join(PLUGIN_DIR, filename);
|
|
68
|
+
fs.writeFileSync(target, content, 'utf8');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Deletes a plugin by name.
|
|
73
|
+
*/
|
|
74
|
+
function deletePlugin(name) {
|
|
75
|
+
const filename = name.endsWith('.js') ? name : `${name}.js`;
|
|
76
|
+
const target = path.join(PLUGIN_DIR, filename);
|
|
77
|
+
if (fs.existsSync(target)) {
|
|
78
|
+
fs.unlinkSync(target);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getPluginDir() {
|
|
85
|
+
return PLUGIN_DIR;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
loadPlugins,
|
|
90
|
+
getPluginDir,
|
|
91
|
+
getAllPluginSource,
|
|
92
|
+
savePlugin,
|
|
93
|
+
deletePlugin
|
|
94
|
+
};
|