error-ux-cli 1.0.0 → 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/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'}, 'Welcome Aniruth')
87
- : e(AnimatedText, {text: 'Welcome Aniruth', speed: 1}),
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,80 @@
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
+ const maskedId = gistId ? `...${gistId.slice(-4)}` : 'MISSING';
38
+ return reject(new Error(`GitHub Error: ${res.statusCode} (ID: ${maskedId}) - ${body}`));
39
+ }
40
+
41
+ try {
42
+ const result = JSON.parse(body);
43
+ if (action === 'pull') {
44
+ // We assume the data is in a file named 'vault.json' or similar.
45
+ // We'll just take the first file's content.
46
+ const files = result.files || {};
47
+ const fileName = Object.keys(files)[0];
48
+ if (fileName) {
49
+ resolve(files[fileName].content);
50
+ } else {
51
+ resolve(null);
52
+ }
53
+ } else {
54
+ resolve(body);
55
+ }
56
+ } catch (e) {
57
+ reject(e);
58
+ }
59
+ });
60
+ });
61
+
62
+ req.on('error', (e) => reject(e));
63
+
64
+ if (action === 'push' && data) {
65
+ const payload = JSON.stringify({
66
+ files: {
67
+ 'vault.json': {
68
+ content: data
69
+ }
70
+ }
71
+ });
72
+ req.write(payload);
73
+ }
74
+
75
+ req.end();
76
+ });
77
+ }
4
78
 
5
79
  function runGit(command) {
6
80
  try {
@@ -90,8 +164,17 @@ async function getNextBranchVersion(name) {
90
164
  async function branchExists(name) {
91
165
  const branch = sanitizeBranchName(name);
92
166
  try {
167
+ // Check local branches and current remote branches
93
168
  const output = runGit(`git branch -a --list ${quoteShellArg(branch)} ${quoteShellArg(`remotes/origin/${branch}`)}`);
94
- return Boolean(String(output || '').trim());
169
+ if (Boolean(String(output || '').trim())) return true;
170
+
171
+ // Force a remote check if possible
172
+ try {
173
+ const remoteOutput = runGit(`git ls-remote --heads origin ${quoteShellArg(branch)}`);
174
+ return Boolean(String(remoteOutput || '').trim());
175
+ } catch (e) {
176
+ return false;
177
+ }
95
178
  } catch (error) {
96
179
  return false;
97
180
  }
@@ -190,8 +273,7 @@ async function getPushContext(args, savedRepos, rl) {
190
273
  }
191
274
 
192
275
  if (!targetRepo || !targetRepo.url) {
193
- console.error('Error: No target repository configured.');
194
- return null;
276
+ targetRepo = { name: 'origin', url: null };
195
277
  }
196
278
 
197
279
  return {
@@ -210,7 +292,12 @@ async function automatedPush(targetRepo, message, branchBase) {
210
292
  if (!currentRemote) {
211
293
  await setRemoteUrl(targetRepo.url, false);
212
294
  } else if (currentRemote !== targetRepo.url) {
213
- await setRemoteUrl(targetRepo.url, true);
295
+ // If it exists but differs, update it
296
+ try {
297
+ await setRemoteUrl(targetRepo.url, true);
298
+ } catch (e) {
299
+ // If it somehow fails, try to force it or just ignore if it's already there
300
+ }
214
301
  }
215
302
 
216
303
  const branch = sanitizeBranchName(branchBase);
@@ -257,6 +344,7 @@ module.exports = {
257
344
  getNextBranchVersion,
258
345
  getRemoteUrl,
259
346
  getRepoRoot,
347
+ gistSync,
260
348
  initRepo,
261
349
  isGitRepo,
262
350
  runGit,
@@ -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') && !file.startsWith('.')) {
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
+ };