buddy-workbench 0.1.26 → 0.1.28
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/package.json +20 -1
- package/server/routes/data-backup.js +285 -0
- package/server/routes/file-organizer.js +2 -2
- package/server/routes/jira-filters.js +71 -8
- package/server/routes/updates.js +140 -0
- package/server.js +4 -0
- package/ui/dist/assets/index-BdUCs_ZS.css +1 -0
- package/ui/dist/assets/index-CvmdTKfk.js +530 -0
- package/ui/dist/index.html +15 -2
- package/ui/dist/assets/index-CsWLS70G.css +0 -1
- package/ui/dist/assets/index-D5Bl4Auu.js +0 -530
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buddy-workbench",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,6 +23,25 @@
|
|
|
23
23
|
"prepublishOnly": "npm run build",
|
|
24
24
|
"version": "node -e \"const v=process.env.npm_package_version; const fs=require('fs'); ['ui/package.json', 'ui/package-lock.json'].forEach(p=>{if(fs.existsSync(p)){const j=JSON.parse(fs.readFileSync(p)); j.version=v; if(j.packages&&j.packages['']){j.packages[''].version=v;} fs.writeFileSync(p, JSON.stringify(j,null,2)+'\\n');}});\" && git add ui/package.json ui/package-lock.json"
|
|
25
25
|
},
|
|
26
|
+
"devbuddyChangelog": [
|
|
27
|
+
{
|
|
28
|
+
"version": "0.1.27",
|
|
29
|
+
"name": "Data backups and settings refinements",
|
|
30
|
+
"notes": [
|
|
31
|
+
"Added data directory size statistics and secure .tar.gz backup import and export.",
|
|
32
|
+
"Refined the Static Pages action column header alignment."
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"version": "0.1.26",
|
|
37
|
+
"name": "Light theme and update center",
|
|
38
|
+
"notes": [
|
|
39
|
+
"Added a switchable light theme with persistent appearance settings.",
|
|
40
|
+
"Refined the light theme window color, primary actions, and navigation styling.",
|
|
41
|
+
"Added an npm-based update indicator and in-app Change Log."
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
],
|
|
26
45
|
"dependencies": {
|
|
27
46
|
"@jsquash/jpeg": "1.6.0",
|
|
28
47
|
"axios": "1.7.9",
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream, existsSync } from 'node:fs';
|
|
2
|
+
import { copyFile, lstat, mkdir, mkdtemp, open, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join, posix, resolve, sep } from 'node:path';
|
|
5
|
+
import { Transform } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { createGunzip, createGzip } from 'node:zlib';
|
|
8
|
+
import { once } from 'node:events';
|
|
9
|
+
import { Router } from 'express';
|
|
10
|
+
import { paths } from '../config.js';
|
|
11
|
+
|
|
12
|
+
const router = Router();
|
|
13
|
+
const TAR_BLOCK_SIZE = 512;
|
|
14
|
+
const MAX_COMPRESSED_BYTES = 512 * 1024 * 1024;
|
|
15
|
+
const MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024;
|
|
16
|
+
const MAX_IMPORTED_FILES = 100000;
|
|
17
|
+
|
|
18
|
+
async function collectFiles(directory, relativeDirectory = '') {
|
|
19
|
+
if (!existsSync(directory)) return [];
|
|
20
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
21
|
+
const files = [];
|
|
22
|
+
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const absolutePath = join(directory, entry.name);
|
|
25
|
+
const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
|
|
26
|
+
if (entry.isDirectory()) {
|
|
27
|
+
files.push(...await collectFiles(absolutePath, relativePath));
|
|
28
|
+
} else if (entry.isFile()) {
|
|
29
|
+
const fileStat = await lstat(absolutePath);
|
|
30
|
+
files.push({
|
|
31
|
+
absolutePath,
|
|
32
|
+
relativePath: relativePath.replaceAll('\\', '/'),
|
|
33
|
+
size: fileStat.size,
|
|
34
|
+
mtime: fileStat.mtime
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return files;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function dataDirectoryStats() {
|
|
43
|
+
const files = await collectFiles(paths.dataDir);
|
|
44
|
+
return {
|
|
45
|
+
sizeBytes: files.reduce((total, file) => total + file.size, 0),
|
|
46
|
+
fileCount: files.length
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function splitTarPath(relativePath) {
|
|
51
|
+
if (Buffer.byteLength(relativePath) <= 100) return { name: relativePath, prefix: '' };
|
|
52
|
+
const separators = [...relativePath.matchAll(/\//g)].map((match) => match.index).reverse();
|
|
53
|
+
for (const index of separators) {
|
|
54
|
+
const prefix = relativePath.slice(0, index);
|
|
55
|
+
const name = relativePath.slice(index + 1);
|
|
56
|
+
if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(name) <= 100) {
|
|
57
|
+
return { name, prefix };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`Backup path is too long: ${relativePath}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeString(buffer, value, offset, length) {
|
|
64
|
+
const encoded = Buffer.from(String(value));
|
|
65
|
+
if (encoded.length > length) throw new Error(`Tar field exceeds ${length} bytes.`);
|
|
66
|
+
encoded.copy(buffer, offset);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writeOctal(buffer, value, offset, length) {
|
|
70
|
+
writeString(buffer, Math.max(0, value).toString(8).padStart(length - 1, '0'), offset, length - 1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createTarHeader(file) {
|
|
74
|
+
const header = Buffer.alloc(TAR_BLOCK_SIZE);
|
|
75
|
+
const { name, prefix } = splitTarPath(file.relativePath);
|
|
76
|
+
writeString(header, name, 0, 100);
|
|
77
|
+
writeOctal(header, 0o600, 100, 8);
|
|
78
|
+
writeOctal(header, 0, 108, 8);
|
|
79
|
+
writeOctal(header, 0, 116, 8);
|
|
80
|
+
writeOctal(header, file.size, 124, 12);
|
|
81
|
+
writeOctal(header, Math.floor(file.mtime.getTime() / 1000), 136, 12);
|
|
82
|
+
header.fill(0x20, 148, 156);
|
|
83
|
+
writeString(header, '0', 156, 1);
|
|
84
|
+
writeString(header, 'ustar', 257, 6);
|
|
85
|
+
writeString(header, '00', 263, 2);
|
|
86
|
+
writeString(header, 'devbuddy', 265, 32);
|
|
87
|
+
writeString(header, 'devbuddy', 297, 32);
|
|
88
|
+
if (prefix) writeString(header, prefix, 345, 155);
|
|
89
|
+
|
|
90
|
+
const checksum = header.reduce((total, byte) => total + byte, 0);
|
|
91
|
+
writeString(header, checksum.toString(8).padStart(6, '0'), 148, 6);
|
|
92
|
+
header[154] = 0;
|
|
93
|
+
header[155] = 0x20;
|
|
94
|
+
return header;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function writeWithBackpressure(stream, chunk) {
|
|
98
|
+
if (!stream.write(chunk)) await once(stream, 'drain');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function nullTerminatedString(buffer, offset, length) {
|
|
102
|
+
const field = buffer.subarray(offset, offset + length);
|
|
103
|
+
const nullIndex = field.indexOf(0);
|
|
104
|
+
return field.subarray(0, nullIndex === -1 ? field.length : nullIndex).toString('utf8').trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseOctal(buffer, offset, length) {
|
|
108
|
+
const value = nullTerminatedString(buffer, offset, length).trim();
|
|
109
|
+
if (!value) return 0;
|
|
110
|
+
const parsed = Number.parseInt(value, 8);
|
|
111
|
+
if (!Number.isFinite(parsed) || parsed < 0) throw new Error('Backup contains an invalid tar size.');
|
|
112
|
+
return parsed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function verifyTarHeader(header) {
|
|
116
|
+
const expected = parseOctal(header, 148, 8);
|
|
117
|
+
const copy = Buffer.from(header);
|
|
118
|
+
copy.fill(0x20, 148, 156);
|
|
119
|
+
const actual = copy.reduce((total, byte) => total + byte, 0);
|
|
120
|
+
if (expected !== actual) throw new Error('Backup archive checksum is invalid.');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function safeArchiveTarget(stagingDirectory, archivePath) {
|
|
124
|
+
const normalizedInput = archivePath.replaceAll('\\', '/');
|
|
125
|
+
const normalized = posix.normalize(normalizedInput);
|
|
126
|
+
if (
|
|
127
|
+
!normalized ||
|
|
128
|
+
normalized === '.' ||
|
|
129
|
+
normalized === '..' ||
|
|
130
|
+
normalized.startsWith('../') ||
|
|
131
|
+
normalized.startsWith('/') ||
|
|
132
|
+
/^[a-zA-Z]:/.test(normalized)
|
|
133
|
+
) {
|
|
134
|
+
throw new Error(`Backup contains an unsafe path: ${archivePath}`);
|
|
135
|
+
}
|
|
136
|
+
const target = resolve(stagingDirectory, ...normalized.split('/'));
|
|
137
|
+
const stagingRoot = resolve(stagingDirectory);
|
|
138
|
+
if (target !== stagingRoot && !target.startsWith(`${stagingRoot}${sep}`)) {
|
|
139
|
+
throw new Error(`Backup path escapes the data directory: ${archivePath}`);
|
|
140
|
+
}
|
|
141
|
+
return target;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function byteLimit(limit, label) {
|
|
145
|
+
let total = 0;
|
|
146
|
+
return new Transform({
|
|
147
|
+
transform(chunk, _encoding, callback) {
|
|
148
|
+
total += chunk.length;
|
|
149
|
+
if (total > limit) {
|
|
150
|
+
callback(new Error(`${label} exceeds the supported size limit.`));
|
|
151
|
+
} else {
|
|
152
|
+
callback(null, chunk);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function extractTar(tarPath, stagingDirectory) {
|
|
159
|
+
const archiveStat = await stat(tarPath);
|
|
160
|
+
const archive = await open(tarPath, 'r');
|
|
161
|
+
let offset = 0;
|
|
162
|
+
let importedFiles = 0;
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
while (offset + TAR_BLOCK_SIZE <= archiveStat.size) {
|
|
166
|
+
const header = Buffer.alloc(TAR_BLOCK_SIZE);
|
|
167
|
+
const { bytesRead } = await archive.read(header, 0, TAR_BLOCK_SIZE, offset);
|
|
168
|
+
if (bytesRead !== TAR_BLOCK_SIZE) throw new Error('Backup archive ended unexpectedly.');
|
|
169
|
+
if (header.every((byte) => byte === 0)) break;
|
|
170
|
+
verifyTarHeader(header);
|
|
171
|
+
|
|
172
|
+
const name = nullTerminatedString(header, 0, 100);
|
|
173
|
+
const prefix = nullTerminatedString(header, 345, 155);
|
|
174
|
+
const archivePath = prefix ? `${prefix}/${name}` : name;
|
|
175
|
+
const type = String.fromCharCode(header[156] || 48);
|
|
176
|
+
const size = parseOctal(header, 124, 12);
|
|
177
|
+
const dataStart = offset + TAR_BLOCK_SIZE;
|
|
178
|
+
const dataEnd = dataStart + size;
|
|
179
|
+
if (dataEnd > archiveStat.size) throw new Error('Backup file data exceeds the archive size.');
|
|
180
|
+
|
|
181
|
+
const target = safeArchiveTarget(stagingDirectory, archivePath);
|
|
182
|
+
if (type === '5') {
|
|
183
|
+
await mkdir(target, { recursive: true });
|
|
184
|
+
} else if (type === '0') {
|
|
185
|
+
importedFiles += 1;
|
|
186
|
+
if (importedFiles > MAX_IMPORTED_FILES) throw new Error('Backup contains too many files.');
|
|
187
|
+
await mkdir(dirname(target), { recursive: true });
|
|
188
|
+
if (size === 0) {
|
|
189
|
+
await writeFile(target, Buffer.alloc(0), { mode: 0o600 });
|
|
190
|
+
} else {
|
|
191
|
+
await pipeline(
|
|
192
|
+
createReadStream(tarPath, { start: dataStart, end: dataEnd - 1 }),
|
|
193
|
+
createWriteStream(target, { mode: 0o600 })
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
throw new Error('Backup contains an unsupported link or special file.');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
offset = dataStart + Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE;
|
|
201
|
+
}
|
|
202
|
+
} finally {
|
|
203
|
+
await archive.close();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return importedFiles;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function mergeDirectory(source, destination) {
|
|
210
|
+
await mkdir(destination, { recursive: true });
|
|
211
|
+
const entries = await readdir(source, { withFileTypes: true });
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
const sourcePath = join(source, entry.name);
|
|
214
|
+
const destinationPath = join(destination, entry.name);
|
|
215
|
+
if (entry.isDirectory()) {
|
|
216
|
+
await mergeDirectory(sourcePath, destinationPath);
|
|
217
|
+
} else if (entry.isFile()) {
|
|
218
|
+
await mkdir(dirname(destinationPath), { recursive: true });
|
|
219
|
+
await copyFile(sourcePath, destinationPath);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
router.get('/stats', async (_req, res, next) => {
|
|
225
|
+
try {
|
|
226
|
+
res.json(await dataDirectoryStats());
|
|
227
|
+
} catch (error) {
|
|
228
|
+
next(error);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
router.get('/export', async (_req, res, next) => {
|
|
233
|
+
try {
|
|
234
|
+
const files = await collectFiles(paths.dataDir);
|
|
235
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
236
|
+
res.setHeader('Content-Type', 'application/gzip');
|
|
237
|
+
res.setHeader('Content-Disposition', `attachment; filename="devbuddy-data-${date}.tar.gz"`);
|
|
238
|
+
|
|
239
|
+
const gzip = createGzip({ level: 6 });
|
|
240
|
+
gzip.pipe(res);
|
|
241
|
+
for (const file of files) {
|
|
242
|
+
await writeWithBackpressure(gzip, createTarHeader(file));
|
|
243
|
+
const readOptions = file.size > 0 ? { end: file.size - 1 } : undefined;
|
|
244
|
+
for await (const chunk of createReadStream(file.absolutePath, readOptions)) {
|
|
245
|
+
await writeWithBackpressure(gzip, chunk);
|
|
246
|
+
}
|
|
247
|
+
const padding = (TAR_BLOCK_SIZE - (file.size % TAR_BLOCK_SIZE)) % TAR_BLOCK_SIZE;
|
|
248
|
+
if (padding) await writeWithBackpressure(gzip, Buffer.alloc(padding));
|
|
249
|
+
}
|
|
250
|
+
await writeWithBackpressure(gzip, Buffer.alloc(TAR_BLOCK_SIZE * 2));
|
|
251
|
+
gzip.end();
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (!res.headersSent) next(error);
|
|
254
|
+
else res.destroy(error);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
router.post('/import', async (req, res, next) => {
|
|
259
|
+
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'devbuddy-backup-'));
|
|
260
|
+
const tarPath = join(temporaryDirectory, 'backup.tar');
|
|
261
|
+
const stagingDirectory = join(temporaryDirectory, 'data');
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
await mkdir(stagingDirectory, { recursive: true });
|
|
265
|
+
await pipeline(
|
|
266
|
+
req,
|
|
267
|
+
byteLimit(MAX_COMPRESSED_BYTES, 'Compressed backup'),
|
|
268
|
+
createGunzip(),
|
|
269
|
+
byteLimit(MAX_EXPANDED_BYTES, 'Expanded backup'),
|
|
270
|
+
createWriteStream(tarPath, { mode: 0o600 })
|
|
271
|
+
);
|
|
272
|
+
const importedFiles = await extractTar(tarPath, stagingDirectory);
|
|
273
|
+
await mergeDirectory(stagingDirectory, paths.dataDir);
|
|
274
|
+
res.json({
|
|
275
|
+
importedFiles,
|
|
276
|
+
...(await dataDirectoryStats())
|
|
277
|
+
});
|
|
278
|
+
} catch (error) {
|
|
279
|
+
next(error);
|
|
280
|
+
} finally {
|
|
281
|
+
await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => {});
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
export default router;
|
|
@@ -16,8 +16,8 @@ router.post('/scan', async (req, res, next) => {
|
|
|
16
16
|
const result = await scanFolder({
|
|
17
17
|
sourcePath,
|
|
18
18
|
targetPath,
|
|
19
|
-
includeSubfolders:
|
|
20
|
-
skipOrganized: skipOrganized !== false
|
|
19
|
+
includeSubfolders: includeSubfolders === true || includeSubfolders === 'true',
|
|
20
|
+
skipOrganized: skipOrganized !== false && skipOrganized !== 'false'
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
res.json(result);
|
|
@@ -112,6 +112,10 @@ router.get('/:id/issues', async (req, res) => {
|
|
|
112
112
|
return res.status(404).json({ error: 'Filter not found.' });
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
if (filter.mockIssues && Array.isArray(filter.mockIssues)) {
|
|
116
|
+
return res.json({ filter, issues: filter.mockIssues });
|
|
117
|
+
}
|
|
118
|
+
|
|
115
119
|
const jiraHost = getJiraHost();
|
|
116
120
|
if (!jiraHost) {
|
|
117
121
|
return res.status(400).json({ error: 'Jira domain is not configured. Please set Domain in Settings.' });
|
|
@@ -121,7 +125,7 @@ router.get('/:id/issues', async (req, res) => {
|
|
|
121
125
|
const token = settings.jiraAccessToken;
|
|
122
126
|
|
|
123
127
|
try {
|
|
124
|
-
const url = `https://${jiraHost}/rest/api/2/search?jql=filter%3D${encodeURIComponent(filter.filterId)}&maxResults=200&fields=summary,priority,duedate,status`;
|
|
128
|
+
const url = `https://${jiraHost}/rest/api/2/search?jql=filter%3D${encodeURIComponent(filter.filterId)}&maxResults=200&fields=summary,priority,duedate,status,issuetype`;
|
|
125
129
|
const headers = {};
|
|
126
130
|
if (token) {
|
|
127
131
|
headers.Authorization = token.startsWith('Bearer ') ? token : `Bearer ${token}`;
|
|
@@ -144,6 +148,7 @@ router.get('/:id/issues', async (req, res) => {
|
|
|
144
148
|
priority: fields.priority || null,
|
|
145
149
|
dueDate: fields.duedate || null,
|
|
146
150
|
status: fields.status?.name || null,
|
|
151
|
+
issueType: fields.issuetype?.name || null,
|
|
147
152
|
url: `https://${jiraHost}/browse/${key}`
|
|
148
153
|
};
|
|
149
154
|
});
|
|
@@ -153,14 +158,14 @@ router.get('/:id/issues', async (req, res) => {
|
|
|
153
158
|
|
|
154
159
|
res.json({ filter, issues });
|
|
155
160
|
} catch (error) {
|
|
156
|
-
const targetUrl = `https://${jiraHost}/rest/api/2/search?jql=filter%3D${encodeURIComponent(filter.filterId)}&maxResults=200&fields=summary,priority,duedate,status`;
|
|
161
|
+
const targetUrl = `https://${jiraHost}/rest/api/2/search?jql=filter%3D${encodeURIComponent(filter.filterId)}&maxResults=200&fields=summary,priority,duedate,status,issuetype`;
|
|
157
162
|
res.status(500).json({ error: error.message || 'Failed to fetch Jira issues.', targetUrl });
|
|
158
163
|
}
|
|
159
164
|
});
|
|
160
165
|
|
|
161
166
|
// Clone an issue
|
|
162
167
|
router.post('/issues/clone', async (req, res) => {
|
|
163
|
-
const { issueKey, summary } = req.body || {};
|
|
168
|
+
const { issueKey, summary, issueType } = req.body || {};
|
|
164
169
|
if (!summary || typeof summary !== 'string' || !summary.trim()) {
|
|
165
170
|
return res.status(400).json({ error: 'Summary is required for clone.' });
|
|
166
171
|
}
|
|
@@ -194,20 +199,78 @@ router.post('/issues/clone', async (req, res) => {
|
|
|
194
199
|
// Ignore if fetching current user fails
|
|
195
200
|
}
|
|
196
201
|
|
|
197
|
-
|
|
202
|
+
// Fetch original issue details to preserve issue type, project, description, priority
|
|
203
|
+
let origIssueType = null;
|
|
204
|
+
let origProjectKey = null;
|
|
205
|
+
let origDescription = null;
|
|
206
|
+
let origPriority = null;
|
|
207
|
+
|
|
208
|
+
if (issueKey) {
|
|
209
|
+
try {
|
|
210
|
+
const issueUrl = `https://${jiraHost}/rest/api/2/issue/${encodeURIComponent(issueKey)}`;
|
|
211
|
+
const origRes = await httpClient.get(issueUrl, { headers });
|
|
212
|
+
if (origRes.status === 200 && origRes.data?.fields) {
|
|
213
|
+
const fields = origRes.data.fields;
|
|
214
|
+
if (fields.issuetype) {
|
|
215
|
+
if (fields.issuetype.id) {
|
|
216
|
+
origIssueType = { id: fields.issuetype.id };
|
|
217
|
+
} else if (fields.issuetype.name) {
|
|
218
|
+
origIssueType = { name: fields.issuetype.name };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (fields.project?.key) {
|
|
222
|
+
origProjectKey = fields.project.key;
|
|
223
|
+
}
|
|
224
|
+
if (fields.description) {
|
|
225
|
+
origDescription = fields.description;
|
|
226
|
+
}
|
|
227
|
+
if (fields.priority) {
|
|
228
|
+
if (fields.priority.id) {
|
|
229
|
+
origPriority = { id: fields.priority.id };
|
|
230
|
+
} else if (fields.priority.name) {
|
|
231
|
+
origPriority = { name: fields.priority.name };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// Ignore if fetching original issue fails
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Determine issue type fallback: fetched original issueType -> passed issueType -> Task
|
|
241
|
+
const targetIssueType = origIssueType || (issueType ? { name: issueType } : { name: 'Task' });
|
|
242
|
+
const projectKey = origProjectKey || (issueKey ? issueKey.split('-')[0] : '');
|
|
198
243
|
const url = `https://${jiraHost}/rest/api/2/issue`;
|
|
199
244
|
const payload = {
|
|
200
245
|
fields: {
|
|
201
246
|
summary: summary.trim(),
|
|
202
247
|
...(projectKey ? { project: { key: projectKey } } : {}),
|
|
203
|
-
issuetype:
|
|
204
|
-
...(assigneeField ? { assignee: assigneeField } : {})
|
|
248
|
+
issuetype: targetIssueType,
|
|
249
|
+
...(assigneeField ? { assignee: assigneeField } : {}),
|
|
250
|
+
...(origDescription ? { description: origDescription } : {}),
|
|
251
|
+
...(origPriority ? { priority: origPriority } : {})
|
|
205
252
|
}
|
|
206
253
|
};
|
|
207
254
|
|
|
208
|
-
|
|
255
|
+
let response = await httpClient.post(url, payload, { headers });
|
|
256
|
+
// Fallback if optional fields like description or priority caused issue creation rejection
|
|
257
|
+
if (response.status !== 201 && response.status !== 200 && (origPriority || origDescription)) {
|
|
258
|
+
const fallbackPayload = {
|
|
259
|
+
fields: {
|
|
260
|
+
summary: summary.trim(),
|
|
261
|
+
...(projectKey ? { project: { key: projectKey } } : {}),
|
|
262
|
+
issuetype: targetIssueType,
|
|
263
|
+
...(assigneeField ? { assignee: assigneeField } : {})
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
response = await httpClient.post(url, fallbackPayload, { headers });
|
|
267
|
+
}
|
|
268
|
+
|
|
209
269
|
if (response.status !== 201 && response.status !== 200) {
|
|
210
|
-
const errMsg = response.data?.errorMessages?.[0] ||
|
|
270
|
+
const errMsg = response.data?.errorMessages?.[0] ||
|
|
271
|
+
(response.data?.errors ? Object.values(response.data.errors).join(', ') : null) ||
|
|
272
|
+
response.data?.message ||
|
|
273
|
+
`Jira API returned status ${response.status}`;
|
|
211
274
|
return res.status(response.status).json({ error: errMsg });
|
|
212
275
|
}
|
|
213
276
|
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { Router } from 'express';
|
|
6
|
+
import { root } from '../config.js';
|
|
7
|
+
|
|
8
|
+
const router = Router();
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
11
|
+
const PACKAGE_NAME = packageJson.name;
|
|
12
|
+
const CURRENT_VERSION = packageJson.version;
|
|
13
|
+
const CACHE_TTL_MS = 15 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
let cachedResult = null;
|
|
16
|
+
let cachedAt = 0;
|
|
17
|
+
|
|
18
|
+
function versionParts(version) {
|
|
19
|
+
return String(version || '')
|
|
20
|
+
.trim()
|
|
21
|
+
.replace(/^v/i, '')
|
|
22
|
+
.split('-')[0]
|
|
23
|
+
.split('.')
|
|
24
|
+
.map((part) => Number.parseInt(part, 10) || 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function compareVersions(left, right) {
|
|
28
|
+
const a = versionParts(left);
|
|
29
|
+
const b = versionParts(right);
|
|
30
|
+
const length = Math.max(a.length, b.length);
|
|
31
|
+
for (let index = 0; index < length; index += 1) {
|
|
32
|
+
const difference = (a[index] || 0) - (b[index] || 0);
|
|
33
|
+
if (difference !== 0) return difference;
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeChangelogEntry(entry) {
|
|
39
|
+
if (!entry || typeof entry !== 'object' || !entry.version) return null;
|
|
40
|
+
return {
|
|
41
|
+
version: String(entry.version).replace(/^v/i, ''),
|
|
42
|
+
name: entry.name || 'Release',
|
|
43
|
+
notes: Array.isArray(entry.notes) ? entry.notes.map((note) => `• ${note}`).join('\n') : String(entry.notes || ''),
|
|
44
|
+
publishedAt: entry.publishedAt || null
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildReleaseList(metadata) {
|
|
49
|
+
const localEntries = Array.isArray(packageJson.devbuddyChangelog)
|
|
50
|
+
? packageJson.devbuddyChangelog.map(normalizeChangelogEntry).filter(Boolean)
|
|
51
|
+
: [];
|
|
52
|
+
const remoteEntries = Array.isArray(metadata.devbuddyChangelog)
|
|
53
|
+
? metadata.devbuddyChangelog.map(normalizeChangelogEntry).filter(Boolean)
|
|
54
|
+
: [];
|
|
55
|
+
const entriesByVersion = new Map();
|
|
56
|
+
|
|
57
|
+
[...remoteEntries, ...localEntries].forEach((entry) => {
|
|
58
|
+
if (!entriesByVersion.has(entry.version)) entriesByVersion.set(entry.version, entry);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (metadata.time && typeof metadata.time === 'object') {
|
|
62
|
+
Object.entries(metadata.time).forEach(([version, publishedAt]) => {
|
|
63
|
+
if (['created', 'modified'].includes(version) || !/^\d+\.\d+\.\d+/.test(version)) return;
|
|
64
|
+
const existing = entriesByVersion.get(version);
|
|
65
|
+
if (existing) {
|
|
66
|
+
if (!existing.publishedAt) existing.publishedAt = publishedAt;
|
|
67
|
+
} else {
|
|
68
|
+
entriesByVersion.set(version, {
|
|
69
|
+
version,
|
|
70
|
+
name: 'Release',
|
|
71
|
+
notes: 'Published to the configured npm registry.',
|
|
72
|
+
publishedAt
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return [...entriesByVersion.values()]
|
|
79
|
+
.sort((left, right) => compareVersions(right.version, left.version))
|
|
80
|
+
.slice(0, 10);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function readNpmPackageMetadata() {
|
|
84
|
+
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
85
|
+
const { stdout } = await execFileAsync(
|
|
86
|
+
npmCommand,
|
|
87
|
+
['view', `${PACKAGE_NAME}@latest`, 'version', 'time', 'devbuddyChangelog', '--json'],
|
|
88
|
+
{
|
|
89
|
+
timeout: 8000,
|
|
90
|
+
maxBuffer: 1024 * 1024,
|
|
91
|
+
windowsHide: true
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
const metadata = JSON.parse(stdout);
|
|
95
|
+
if (!metadata || typeof metadata !== 'object' || !metadata.version) {
|
|
96
|
+
throw new Error('The configured npm registry did not return package version metadata.');
|
|
97
|
+
}
|
|
98
|
+
return metadata;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function checkForUpdates() {
|
|
102
|
+
const metadata = await readNpmPackageMetadata();
|
|
103
|
+
const latestVersion = String(metadata.version).replace(/^v/i, '');
|
|
104
|
+
return {
|
|
105
|
+
currentVersion: CURRENT_VERSION,
|
|
106
|
+
latestVersion,
|
|
107
|
+
updateAvailable: compareVersions(latestVersion, CURRENT_VERSION) > 0,
|
|
108
|
+
checkedAt: new Date().toISOString(),
|
|
109
|
+
releases: buildReleaseList(metadata),
|
|
110
|
+
sourceUnavailable: false
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function unavailableResult() {
|
|
115
|
+
return {
|
|
116
|
+
currentVersion: CURRENT_VERSION,
|
|
117
|
+
latestVersion: CURRENT_VERSION,
|
|
118
|
+
updateAvailable: false,
|
|
119
|
+
checkedAt: new Date().toISOString(),
|
|
120
|
+
releases: buildReleaseList({}),
|
|
121
|
+
sourceUnavailable: true
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
router.get('/', async (req, res) => {
|
|
126
|
+
const forceRefresh = req.query.refresh === '1';
|
|
127
|
+
if (!forceRefresh && cachedResult && Date.now() - cachedAt < CACHE_TTL_MS) {
|
|
128
|
+
return res.json(cachedResult);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
cachedResult = await checkForUpdates();
|
|
133
|
+
} catch {
|
|
134
|
+
cachedResult = unavailableResult();
|
|
135
|
+
}
|
|
136
|
+
cachedAt = Date.now();
|
|
137
|
+
return res.json(cachedResult);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
export default router;
|
package/server.js
CHANGED
|
@@ -21,6 +21,8 @@ import postmanRoutes from './server/routes/postman.js';
|
|
|
21
21
|
import bookmarkSyncRoutes from './server/routes/bookmark-sync.js';
|
|
22
22
|
import fileOrganizerRoutes from './server/routes/file-organizer.js';
|
|
23
23
|
import branchSyncRoutes from './server/routes/branch-sync.js';
|
|
24
|
+
import updateRoutes from './server/routes/updates.js';
|
|
25
|
+
import dataBackupRoutes from './server/routes/data-backup.js';
|
|
24
26
|
import { addErrorRecord } from './server/repositories/errors.js';
|
|
25
27
|
import { startClipboardCapture } from './server/services/clipboard-history.js';
|
|
26
28
|
|
|
@@ -77,6 +79,8 @@ app.use('/api/postman', postmanRoutes);
|
|
|
77
79
|
app.use('/api/bookmark-sync', bookmarkSyncRoutes);
|
|
78
80
|
app.use('/api/file-organizer', fileOrganizerRoutes);
|
|
79
81
|
app.use('/api/branch-sync', branchSyncRoutes);
|
|
82
|
+
app.use('/api/updates', updateRoutes);
|
|
83
|
+
app.use('/api/data-backup', dataBackupRoutes);
|
|
80
84
|
|
|
81
85
|
app.use((err, req, res, _next) => {
|
|
82
86
|
addErrorRecord({
|