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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-workbench",
3
- "version": "0.1.26",
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: Boolean(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
- const projectKey = issueKey ? issueKey.split('-')[0] : '';
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: { name: 'Task' },
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
- const response = await httpClient.post(url, payload, { headers });
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] || response.data?.message || `Jira API returned status ${response.status}`;
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({