error-ux-cli 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/README.md +7 -0
- package/index.js +154 -0
- package/lib/auth.js +52 -0
- package/lib/commands.js +928 -0
- package/lib/dashboard.mjs +152 -0
- package/lib/git.js +265 -0
- package/lib/news.js +51 -0
- package/lib/storage.js +119 -0
- package/lib/utils.js +386 -0
- package/package.json +32 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import React, {useEffect, useState} from 'react';
|
|
2
|
+
import {Box, Text, render, useApp} from 'ink';
|
|
3
|
+
|
|
4
|
+
const e = React.createElement;
|
|
5
|
+
|
|
6
|
+
function fitText(value, width) {
|
|
7
|
+
const text = String(value ?? '');
|
|
8
|
+
if (text.length <= width) return text;
|
|
9
|
+
return text.slice(0, Math.max(0, width - 1)) + '\u2026';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function useTicker(intervalMs = 120) {
|
|
13
|
+
const [tick, setTick] = useState(0);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const timer = setInterval(() => {
|
|
17
|
+
setTick(current => current + 1);
|
|
18
|
+
}, intervalMs);
|
|
19
|
+
|
|
20
|
+
return () => clearInterval(timer);
|
|
21
|
+
}, [intervalMs]);
|
|
22
|
+
|
|
23
|
+
return tick;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function AnimatedText({text, speed = 2}) {
|
|
27
|
+
const [tick, setTick] = useState(0);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const timer = setInterval(() => {
|
|
31
|
+
setTick(current => Math.min(text.length, current + speed));
|
|
32
|
+
}, 35);
|
|
33
|
+
|
|
34
|
+
return () => clearInterval(timer);
|
|
35
|
+
}, [speed, text.length]);
|
|
36
|
+
|
|
37
|
+
return e(Text, null, text.slice(0, tick));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function RotatingCube() {
|
|
41
|
+
const tick = useTicker(140);
|
|
42
|
+
const frames = [
|
|
43
|
+
[' +----+', ' / /|', ' +----+ |', ' | | +', ' | |/', ' +----+'],
|
|
44
|
+
[' .----.', ' / /|', '.----. |', '| | .', '| |/', "'----'"],
|
|
45
|
+
[' +----+', ' |\\ \\', ' | +----+', ' + | |', ' \\| |', ' +----+'],
|
|
46
|
+
[' .----.', ' |\\ \\', ' | .----.', ' . | |', ' \\| |', " '----'"]
|
|
47
|
+
];
|
|
48
|
+
const frame = frames[tick % frames.length];
|
|
49
|
+
|
|
50
|
+
return e(
|
|
51
|
+
Box,
|
|
52
|
+
{flexDirection: 'column', marginRight: 2},
|
|
53
|
+
...frame.map((line, index) => e(Text, {key: `cube-${index}`, color: 'cyan'}, line))
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function LoadingBar({width = 24}) {
|
|
58
|
+
const tick = useTicker(90);
|
|
59
|
+
const filled = tick % (width + 1);
|
|
60
|
+
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(width - filled);
|
|
61
|
+
|
|
62
|
+
return e(Text, {color: 'green'}, `[${bar}]`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function LiveStatus() {
|
|
66
|
+
const tick = useTicker(180);
|
|
67
|
+
const statuses = [
|
|
68
|
+
'rotating cube',
|
|
69
|
+
'drawing dashboard',
|
|
70
|
+
'warming command deck',
|
|
71
|
+
'syncing panels'
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
return e(Text, {color: 'yellow'}, statuses[tick % statuses.length]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function IntroPanel({done}) {
|
|
78
|
+
return e(
|
|
79
|
+
Box,
|
|
80
|
+
{borderStyle: 'round', width: 68, paddingX: 1, marginBottom: 1},
|
|
81
|
+
e(RotatingCube),
|
|
82
|
+
e(
|
|
83
|
+
Box,
|
|
84
|
+
{flexDirection: 'column', justifyContent: 'center'},
|
|
85
|
+
done
|
|
86
|
+
? e(Text, {bold: true, color: 'green'}, 'Welcome Aniruth')
|
|
87
|
+
: e(AnimatedText, {text: 'Welcome Aniruth', speed: 1}),
|
|
88
|
+
e(Text, {dimColor: true}, 'terminal cockpit online'),
|
|
89
|
+
e(LiveStatus),
|
|
90
|
+
e(LoadingBar, {width: 24})
|
|
91
|
+
)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function SectionTitle({children}) {
|
|
96
|
+
return e(Text, {bold: true, color: 'cyan'}, children);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function Dashboard({data}) {
|
|
100
|
+
const {exit} = useApp();
|
|
101
|
+
const [stage, setStage] = useState(0);
|
|
102
|
+
const maxRows = Math.max(data.todoLines.length, data.shortcutLines.length, 4);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const timers = [
|
|
106
|
+
setTimeout(() => setStage(1), 1400),
|
|
107
|
+
setTimeout(() => setStage(2), 2400),
|
|
108
|
+
setTimeout(() => setStage(3), 3400),
|
|
109
|
+
setTimeout(() => exit(), 8000)
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
return () => timers.forEach(clearTimeout);
|
|
113
|
+
}, [exit]);
|
|
114
|
+
|
|
115
|
+
return e(
|
|
116
|
+
Box,
|
|
117
|
+
{flexDirection: 'column'},
|
|
118
|
+
e(IntroPanel, {done: stage > 0}),
|
|
119
|
+
stage >= 1 && e(
|
|
120
|
+
Box,
|
|
121
|
+
{borderStyle: 'double', width: 68, marginBottom: 1},
|
|
122
|
+
e(Text, {color: 'yellow'}, `${data.menu}${' '.repeat(Math.max(0, 61 - data.menu.length - data.dateTime.length))}${data.dateTime}`)
|
|
123
|
+
),
|
|
124
|
+
stage >= 2 && e(
|
|
125
|
+
Box,
|
|
126
|
+
{flexDirection: 'row', marginBottom: 1},
|
|
127
|
+
e(
|
|
128
|
+
Box,
|
|
129
|
+
{borderStyle: 'round', width: 38, flexDirection: 'column', marginRight: 2},
|
|
130
|
+
e(SectionTitle, null, 'ACTIVE TASKS (todo)'),
|
|
131
|
+
...Array.from({length: maxRows}, (_, index) => e(Text, {key: `task-${index}`}, fitText(data.todoLines[index] || '', 32)))
|
|
132
|
+
),
|
|
133
|
+
e(
|
|
134
|
+
Box,
|
|
135
|
+
{borderStyle: 'round', width: 28, flexDirection: 'column'},
|
|
136
|
+
e(SectionTitle, null, 'SHORTCUTS (links)'),
|
|
137
|
+
...Array.from({length: maxRows}, (_, index) => e(Text, {key: `shortcut-${index}`}, fitText(data.shortcutLines[index] || '', 22)))
|
|
138
|
+
)
|
|
139
|
+
),
|
|
140
|
+
stage >= 3 && e(
|
|
141
|
+
Box,
|
|
142
|
+
{borderStyle: 'round', width: 68, flexDirection: 'column'},
|
|
143
|
+
e(SectionTitle, null, 'LATEST NEWS (news)'),
|
|
144
|
+
...data.newsLines.map((line, index) => e(Text, {key: `news-${index}`}, fitText(line, 62)))
|
|
145
|
+
)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function renderDashboard(data) {
|
|
150
|
+
const instance = render(e(Dashboard, {data}));
|
|
151
|
+
await instance.waitUntilExit();
|
|
152
|
+
}
|
package/lib/git.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const fs = require('fs/promises');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
function runGit(command) {
|
|
6
|
+
try {
|
|
7
|
+
return execSync(command, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
8
|
+
} catch (error) {
|
|
9
|
+
throw error;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function quoteShellArg(value) {
|
|
14
|
+
return `"${String(value).replace(/"/g, '\\"')}"`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sanitizeBranchName(value) {
|
|
18
|
+
return String(value || 'update')
|
|
19
|
+
.trim()
|
|
20
|
+
.replace(/\s+/g, '-')
|
|
21
|
+
.replace(/[^A-Za-z0-9._/-]/g, '-')
|
|
22
|
+
.replace(/-+/g, '-')
|
|
23
|
+
.replace(/^[-/.]+|[-/.]+$/g, '') || 'update';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function isGitRepo() {
|
|
27
|
+
try {
|
|
28
|
+
runGit('git rev-parse --is-inside-work-tree');
|
|
29
|
+
return true;
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function getRepoRoot() {
|
|
36
|
+
try {
|
|
37
|
+
const root = runGit('git rev-parse --show-toplevel');
|
|
38
|
+
return path.resolve(root);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function getRemoteUrl() {
|
|
45
|
+
try {
|
|
46
|
+
return runGit('git remote get-url origin');
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function setRemoteUrl(url, exists) {
|
|
53
|
+
if (exists) {
|
|
54
|
+
runGit(`git remote set-url origin ${quoteShellArg(url)}`);
|
|
55
|
+
} else {
|
|
56
|
+
runGit(`git remote add origin ${quoteShellArg(url)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function initRepo() {
|
|
61
|
+
runGit('git init');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function getNextBranchVersion(name) {
|
|
65
|
+
try {
|
|
66
|
+
// Try fetch but ignore errors (e.g. no internet, bad remote)
|
|
67
|
+
try {
|
|
68
|
+
runGit('git fetch --all');
|
|
69
|
+
} catch (e) {
|
|
70
|
+
// Silence fetch errors for versioning
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const branches = runGit('git branch -a');
|
|
74
|
+
const regex = new RegExp(`${name}_v(\\d+)`, 'g');
|
|
75
|
+
let maxVersion = 0;
|
|
76
|
+
let match;
|
|
77
|
+
|
|
78
|
+
while ((match = regex.exec(branches)) !== null) {
|
|
79
|
+
const version = parseInt(match[1]);
|
|
80
|
+
if (version > maxVersion) maxVersion = version;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return maxVersion + 1;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
// Fallback to v1 only if we can't even list branches
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function branchExists(name) {
|
|
91
|
+
const branch = sanitizeBranchName(name);
|
|
92
|
+
try {
|
|
93
|
+
const output = runGit(`git branch -a --list ${quoteShellArg(branch)} ${quoteShellArg(`remotes/origin/${branch}`)}`);
|
|
94
|
+
return Boolean(String(output || '').trim());
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function updateREADME(version, message) {
|
|
101
|
+
const readmePath = path.join(process.cwd(), 'README.md');
|
|
102
|
+
let content = '';
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
content = await fs.readFile(readmePath, 'utf8');
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error.code === 'ENOENT') {
|
|
108
|
+
content = '# Project\n\n## 🚀 Changelog\n';
|
|
109
|
+
} else {
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const changelogHeader = '## 🚀 Changelog';
|
|
115
|
+
const newEntry = `v${version} - ${message}`;
|
|
116
|
+
|
|
117
|
+
if (content.includes(newEntry)) return; // Prevent duplicates
|
|
118
|
+
|
|
119
|
+
if (content.includes(changelogHeader)) {
|
|
120
|
+
const parts = content.split(changelogHeader);
|
|
121
|
+
content = parts[0] + changelogHeader + '\n\n' + newEntry + '\n' + parts[1].trim();
|
|
122
|
+
} else {
|
|
123
|
+
content = content.trim() + '\n\n' + changelogHeader + '\n\n' + newEntry + '\n';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await fs.writeFile(readmePath, content, 'utf8');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function getDashboardStatus() {
|
|
130
|
+
try {
|
|
131
|
+
if (!(await isGitRepo())) return null;
|
|
132
|
+
const branch = runGit('git branch --show-current');
|
|
133
|
+
const status = runGit('git status --porcelain');
|
|
134
|
+
const changes = status ? status.split('\n').filter(l => l.trim()).length : 0;
|
|
135
|
+
return { branch, changes };
|
|
136
|
+
} catch (err) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function getPushContext(args, savedRepos, rl) {
|
|
142
|
+
const parts = String(args || '').trim().split(/\s+/).filter(Boolean);
|
|
143
|
+
if (parts.length === 0) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let repoName = null;
|
|
148
|
+
let repoUrl = null;
|
|
149
|
+
const remaining = [];
|
|
150
|
+
|
|
151
|
+
for (let index = 0; index < parts.length; index++) {
|
|
152
|
+
const part = parts[index];
|
|
153
|
+
if (part === '--repo' && index + 1 < parts.length) {
|
|
154
|
+
repoName = parts[index + 1];
|
|
155
|
+
index++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (part === '--repo-url' && index + 1 < parts.length) {
|
|
159
|
+
repoUrl = parts[index + 1];
|
|
160
|
+
index++;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
remaining.push(part);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const branchBase = remaining[0];
|
|
167
|
+
const message = remaining.slice(1).join(' ') || branchBase || 'update';
|
|
168
|
+
const knownRepos = Array.isArray(savedRepos) ? savedRepos : [];
|
|
169
|
+
|
|
170
|
+
let targetRepo = null;
|
|
171
|
+
if (repoUrl) {
|
|
172
|
+
targetRepo = {name: repoName || 'origin', url: repoUrl};
|
|
173
|
+
} else if (repoName) {
|
|
174
|
+
targetRepo = knownRepos.find((repo) => repo.name === repoName) || null;
|
|
175
|
+
} else {
|
|
176
|
+
const currentRemote = await getRemoteUrl();
|
|
177
|
+
if (currentRemote) {
|
|
178
|
+
targetRepo = knownRepos.find((repo) => repo.url === currentRemote) || {name: 'origin', url: currentRemote};
|
|
179
|
+
} else if (knownRepos.length === 1) {
|
|
180
|
+
targetRepo = knownRepos[0];
|
|
181
|
+
} else if (knownRepos.length > 1 && rl && typeof rl.question === 'function') {
|
|
182
|
+
console.log('\nAvailable repositories:');
|
|
183
|
+
knownRepos.forEach((repo, index) => console.log(`${index + 1}. ${repo.name} (${repo.url})`));
|
|
184
|
+
const answer = await new Promise((resolve) => rl.question('Select repository (number): ', resolve));
|
|
185
|
+
const selectedIndex = Number.parseInt(answer, 10) - 1;
|
|
186
|
+
if (selectedIndex >= 0 && selectedIndex < knownRepos.length) {
|
|
187
|
+
targetRepo = knownRepos[selectedIndex];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!targetRepo || !targetRepo.url) {
|
|
193
|
+
console.error('Error: No target repository configured.');
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
branchBase: branchBase || 'update',
|
|
199
|
+
message,
|
|
200
|
+
targetRepo
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function automatedPush(targetRepo, message, branchBase) {
|
|
205
|
+
if (!(await isGitRepo())) {
|
|
206
|
+
await initRepo();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const currentRemote = await getRemoteUrl();
|
|
210
|
+
if (!currentRemote) {
|
|
211
|
+
await setRemoteUrl(targetRepo.url, false);
|
|
212
|
+
} else if (currentRemote !== targetRepo.url) {
|
|
213
|
+
await setRemoteUrl(targetRepo.url, true);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const branch = sanitizeBranchName(branchBase);
|
|
217
|
+
let branchExisted = true;
|
|
218
|
+
try {
|
|
219
|
+
runGit(`git rev-parse --verify ${quoteShellArg(branch)}`);
|
|
220
|
+
runGit(`git checkout ${quoteShellArg(branch)}`);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
branchExisted = false;
|
|
223
|
+
runGit(`git checkout -b ${quoteShellArg(branch)}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
runGit('git add .');
|
|
227
|
+
|
|
228
|
+
let committed = true;
|
|
229
|
+
try {
|
|
230
|
+
runGit(`git commit -m ${quoteShellArg(message || 'update')}`);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
const stdout = error.stdout || '';
|
|
233
|
+
const stderr = error.stderr || '';
|
|
234
|
+
const combined = `${stdout}\n${stderr}`;
|
|
235
|
+
if (!combined.includes('nothing to commit')) {
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
committed = false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
runGit(`git push -u origin ${branch}`);
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
branch,
|
|
245
|
+
branchExisted,
|
|
246
|
+
committed,
|
|
247
|
+
message: message || 'update',
|
|
248
|
+
remoteUrl: targetRepo.url
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
automatedPush,
|
|
254
|
+
branchExists,
|
|
255
|
+
getPushContext,
|
|
256
|
+
getDashboardStatus,
|
|
257
|
+
getNextBranchVersion,
|
|
258
|
+
getRemoteUrl,
|
|
259
|
+
getRepoRoot,
|
|
260
|
+
initRepo,
|
|
261
|
+
isGitRepo,
|
|
262
|
+
runGit,
|
|
263
|
+
setRemoteUrl,
|
|
264
|
+
updateREADME
|
|
265
|
+
};
|
package/lib/news.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
|
|
3
|
+
async function fetchArticles(query, limit, apiKey) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
let url;
|
|
6
|
+
if (query) {
|
|
7
|
+
url = `https://gnews.io/api/v4/search?q=${encodeURIComponent(query)}&max=${limit}&lang=en&apikey=${apiKey}`;
|
|
8
|
+
} else {
|
|
9
|
+
url = `https://gnews.io/api/v4/top-headlines?category=general&max=${limit}&lang=en&apikey=${apiKey}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
https.get(url, (res) => {
|
|
13
|
+
let data = '';
|
|
14
|
+
res.on('data', (chunk) => {
|
|
15
|
+
data += chunk;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
res.on('end', () => {
|
|
19
|
+
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
20
|
+
return resolve({ error: 'AUTH_ERROR' });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const json = JSON.parse(data);
|
|
25
|
+
|
|
26
|
+
if (res.statusCode !== 200) {
|
|
27
|
+
const errorMsg = json.errors ? json.errors.join(', ') : 'Unknown error';
|
|
28
|
+
if (errorMsg.toLowerCase().includes('api key')) {
|
|
29
|
+
return resolve({ error: 'AUTH_ERROR' });
|
|
30
|
+
}
|
|
31
|
+
return reject(new Error(errorMsg));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (json.articles) {
|
|
35
|
+
resolve({ articles: json.articles });
|
|
36
|
+
} else {
|
|
37
|
+
resolve({ articles: [] });
|
|
38
|
+
}
|
|
39
|
+
} catch (e) {
|
|
40
|
+
reject(new Error('Failed to parse news data'));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}).on('error', (err) => {
|
|
44
|
+
reject(err);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
fetchArticles
|
|
51
|
+
};
|
package/lib/storage.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const fs = require('fs/promises');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const CONFIG_ENV_VAR = 'ERROR_UX_CONFIG_FILE';
|
|
6
|
+
const DEFAULT_CONFIG_FILE = path.join(os.homedir(), '.error-ux', 'config.json');
|
|
7
|
+
const LEGACY_CONFIG_FILE = path.join(os.homedir(), '.mycli_config.json');
|
|
8
|
+
|
|
9
|
+
function getConfigFile() {
|
|
10
|
+
const override = process.env[CONFIG_ENV_VAR];
|
|
11
|
+
return override ? path.resolve(override) : DEFAULT_CONFIG_FILE;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getCandidateConfigFiles() {
|
|
15
|
+
return [...new Set([getConfigFile(), LEGACY_CONFIG_FILE])];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeConfig(config) {
|
|
19
|
+
if (!config || typeof config !== 'object') {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!config.users || typeof config.users !== 'object') {
|
|
24
|
+
const oldName = String(config.name || 'Aniruth').trim() || 'Aniruth';
|
|
25
|
+
config = {
|
|
26
|
+
activeUser: oldName,
|
|
27
|
+
users: {
|
|
28
|
+
[oldName]: {
|
|
29
|
+
password_hash: config.password_hash || '',
|
|
30
|
+
news_api_key: config.news_api_key || null,
|
|
31
|
+
shortcuts: config.shortcuts || {},
|
|
32
|
+
activeRepo: config.activeRepo || null,
|
|
33
|
+
savedRepos: Array.isArray(config.savedRepos) ? config.savedRepos : [],
|
|
34
|
+
todo: Array.isArray(config.todo) ? config.todo : []
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const usernames = Object.keys(config.users);
|
|
41
|
+
if (!config.activeUser || !config.users[config.activeUser]) {
|
|
42
|
+
config.activeUser = usernames[0] || null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
usernames.forEach((username) => {
|
|
46
|
+
const user = config.users[username] || {};
|
|
47
|
+
user.shortcuts = user.shortcuts && typeof user.shortcuts === 'object' ? user.shortcuts : {};
|
|
48
|
+
user.savedRepos = Array.isArray(user.savedRepos) ? user.savedRepos : [];
|
|
49
|
+
user.todo = Array.isArray(user.todo) ? user.todo : [];
|
|
50
|
+
user.activeRepo = user.activeRepo || null;
|
|
51
|
+
user.news_api_key = user.news_api_key || null;
|
|
52
|
+
user.password_hash = user.password_hash || '';
|
|
53
|
+
config.users[username] = user;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return config;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function saveConfig(config) {
|
|
60
|
+
if (!config) return;
|
|
61
|
+
|
|
62
|
+
const targetFile = getConfigFile();
|
|
63
|
+
await fs.mkdir(path.dirname(targetFile), {recursive: true});
|
|
64
|
+
await fs.writeFile(targetFile, JSON.stringify(config, null, 2), 'utf8');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function loadConfig() {
|
|
68
|
+
for (const candidate of getCandidateConfigFiles()) {
|
|
69
|
+
try {
|
|
70
|
+
const data = await fs.readFile(candidate, 'utf8');
|
|
71
|
+
const normalized = normalizeConfig(JSON.parse(data));
|
|
72
|
+
if (!normalized) return null;
|
|
73
|
+
|
|
74
|
+
if (candidate !== getConfigFile()) {
|
|
75
|
+
await saveConfig(normalized);
|
|
76
|
+
} else if (!normalized.users || !normalized.users[normalized.activeUser]) {
|
|
77
|
+
await saveConfig(normalized);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return normalized;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code === 'ENOENT') continue;
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getActiveUser(config) {
|
|
91
|
+
if (!config || !config.users || !config.activeUser) return null;
|
|
92
|
+
return config.users[config.activeUser] || null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function wipeAllData() {
|
|
96
|
+
let removed = false;
|
|
97
|
+
|
|
98
|
+
for (const candidate of getCandidateConfigFiles()) {
|
|
99
|
+
try {
|
|
100
|
+
await fs.unlink(candidate);
|
|
101
|
+
removed = true;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error.code !== 'ENOENT') throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return removed;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
CONFIG_ENV_VAR,
|
|
112
|
+
DEFAULT_CONFIG_FILE,
|
|
113
|
+
LEGACY_CONFIG_FILE,
|
|
114
|
+
getActiveUser,
|
|
115
|
+
getConfigFile,
|
|
116
|
+
loadConfig,
|
|
117
|
+
saveConfig,
|
|
118
|
+
wipeAllData
|
|
119
|
+
};
|