buddy-workbench 0.1.100 → 0.1.102

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.100",
3
+ "version": "0.1.102",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
package/server/config.js CHANGED
@@ -35,6 +35,7 @@ export const paths = {
35
35
  errors: join(userDataDir, 'errors.json'),
36
36
  postman: join(userDataDir, 'postman.json'),
37
37
  branchSync: join(userDataDir, 'branch-sync.json'),
38
+ branchHealthHistory: join(userDataDir, 'branch-health-history.json'),
38
39
  packageUpgrade: join(userDataDir, 'package-upgrade.json'),
39
40
  presentations: join(userDataDir, 'presentations.json'),
40
41
  mindMaps: join(userDataDir, 'mind-maps'),
@@ -5,8 +5,15 @@ const execFileAsync = promisify(execFile);
5
5
  const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
6
6
 
7
7
  async function portPids(port) {
8
- const { stdout } = await execFileAsync('lsof', ['-ti', `:${port}`]);
9
- return [...new Set(stdout.trim().split(/\s+/).filter(Boolean))];
8
+ try {
9
+ const { stdout } = await execFileAsync('lsof', ['-ti', `:${port}`], { timeout: 1500 });
10
+ return [...new Set(stdout.trim().split(/\s+/).filter(Boolean))];
11
+ } catch (error) {
12
+ // Port cleanup is best-effort. Some managed environments block lsof or
13
+ // do not expose the process list; the server can still attempt to bind.
14
+ if (['ETIMEDOUT', 'EPERM', 'EACCES'].includes(error.code) || error.killed) return [];
15
+ throw error;
16
+ }
10
17
  }
11
18
 
12
19
  export async function freePort(port) {
@@ -0,0 +1,41 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { paths } from '../config.js';
5
+
6
+ export function listBranchHealthHistory() {
7
+ if (!existsSync(paths.branchHealthHistory)) return [];
8
+ try {
9
+ const records = JSON.parse(readFileSync(paths.branchHealthHistory, 'utf8'));
10
+ return Array.isArray(records) ? records : [];
11
+ } catch {
12
+ return [];
13
+ }
14
+ }
15
+
16
+ export function saveBranchHealthHistory(records) {
17
+ mkdirSync(dirname(paths.branchHealthHistory), { recursive: true });
18
+ writeFileSync(paths.branchHealthHistory, JSON.stringify(records, null, 2));
19
+ }
20
+
21
+ export function addBranchHealthHistory({ repository, branch, targets, status }) {
22
+ const successfulTargets = Array.isArray(targets) ? targets.filter(Boolean) : [];
23
+ if (!repository?.name || !branch || successfulTargets.length === 0) return null;
24
+
25
+ const record = {
26
+ id: crypto.randomUUID(),
27
+ deletedAt: new Date().toISOString(),
28
+ repository: {
29
+ name: String(repository.name),
30
+ path: String(repository.path || ''),
31
+ remoteUrl: repository.remoteUrl || null
32
+ },
33
+ branch: String(branch),
34
+ targets: successfulTargets,
35
+ status: status === 'partial' ? 'partial' : 'success'
36
+ };
37
+ const records = listBranchHealthHistory();
38
+ records.push(record);
39
+ saveBranchHealthHistory(records);
40
+ return record;
41
+ }
@@ -0,0 +1,362 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { Router } from 'express';
6
+ import { addBranchHealthHistory, listBranchHealthHistory } from '../repositories/branch-health-history.js';
7
+
8
+ const router = Router();
9
+ const execFileAsync = promisify(execFile);
10
+ const GIT_TIMEOUT = 30 * 1000;
11
+
12
+ async function git(folder, args, options = {}) {
13
+ try {
14
+ const result = await execFileAsync('git', args, {
15
+ cwd: folder,
16
+ timeout: GIT_TIMEOUT,
17
+ maxBuffer: 4 * 1024 * 1024
18
+ });
19
+ return result.stdout.trim();
20
+ } catch (error) {
21
+ if (options.allowFailure) return '';
22
+ throw new Error(error.stderr?.trim() || error.message || 'Git command failed.');
23
+ }
24
+ }
25
+
26
+ function repositoryName(folder) {
27
+ return basename(folder) || folder;
28
+ }
29
+
30
+ function workspaceRepositoryCandidates(workspaceDir) {
31
+ const root = resolve(String(workspaceDir || ''));
32
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
33
+ throw new Error(`Workspace directory does not exist: ${root}`);
34
+ }
35
+
36
+ const folders = [];
37
+ if (existsSync(join(root, '.git'))) folders.push(root);
38
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
39
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
40
+ const folder = join(root, entry.name);
41
+ if (existsSync(join(folder, '.git'))) folders.push(folder);
42
+ }
43
+
44
+ return folders.map((folder) => ({
45
+ name: repositoryName(folder),
46
+ path: folder
47
+ }));
48
+ }
49
+
50
+ function parseRefList(raw) {
51
+ return raw.split(/\r?\n/).filter(Boolean).map((line) => {
52
+ const separatorIndex = line.indexOf(' ');
53
+ const ref = separatorIndex === -1 ? line : line.slice(0, separatorIndex);
54
+ const hash = separatorIndex === -1 ? '' : line.slice(separatorIndex + 1).trim();
55
+ if (!ref || !hash) return null;
56
+ const remote = ref.startsWith('refs/remotes/');
57
+ const remoteMatch = ref.match(/^refs\/remotes\/([^/]+)\/(.+)$/);
58
+ const name = remote ? remoteMatch?.[2] : ref.replace(/^refs\/heads\//, '');
59
+ if (!name || name === 'HEAD' || name.endsWith('/HEAD')) return null;
60
+ return { ref, name, hash, remote, remoteName: remoteMatch?.[1] || null };
61
+ }).filter(Boolean);
62
+ }
63
+
64
+ function parseCommit(raw) {
65
+ const [hash, shortHash, subject, authorName, authorEmail, authoredAt, committedAt, committerName, committerEmail] = raw.split('\x1f');
66
+ if (!hash) return null;
67
+ return {
68
+ hash,
69
+ shortHash,
70
+ subject,
71
+ author: { name: authorName, email: authorEmail },
72
+ committer: { name: committerName, email: committerEmail },
73
+ authoredAt,
74
+ committedAt
75
+ };
76
+ }
77
+
78
+ function parseFirstCommit(raw) {
79
+ const [timestamp, date, name, email] = raw.split('\x1f');
80
+ const numericTimestamp = Number(timestamp);
81
+ return {
82
+ createdAt: Number.isFinite(numericTimestamp) ? new Date(numericTimestamp * 1000).toISOString() : date || null,
83
+ creator: { name: name || '', email: email || '' }
84
+ };
85
+ }
86
+
87
+ function isProtectedBranch(name) {
88
+ return /^(main|master|develop|development|trunk|release(?:\/|$))/i.test(name);
89
+ }
90
+
91
+ function dateAgeDays(date) {
92
+ if (!date) return null;
93
+ const timestamp = Date.parse(date);
94
+ if (Number.isNaN(timestamp)) return null;
95
+ return Math.max(0, Math.floor((Date.now() - timestamp) / 86400000));
96
+ }
97
+
98
+ function healthForBranch({ merged, ahead, ageDays, isCurrent, isProtected, hasRemote }) {
99
+ if (isCurrent || isProtected) return { status: 'blocked', label: 'Blocked', score: 0 };
100
+ if (ahead > 0) return { status: 'keep', label: 'Keep', score: 20 };
101
+
102
+ let score = 0;
103
+ if (merged) score += 50;
104
+ if (ageDays >= 180) score += 30;
105
+ else if (ageDays >= 90) score += 25;
106
+ else if (ageDays >= 30) score += 15;
107
+ if (!hasRemote) score += 5;
108
+
109
+ if (merged && ageDays >= 90) return { status: 'safe', label: 'Safe to delete', score };
110
+ if (score >= 45) return { status: 'review', label: 'Review', score };
111
+ return { status: 'keep', label: 'Keep', score };
112
+ }
113
+
114
+ async function readBranchDetails(folder, branch, refs, targetRef, currentBranch) {
115
+ const ref = branch.localRef || branch.remoteRef;
116
+ const commit = parseCommit(await git(folder, [
117
+ 'show', '-s', '--format=%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cI%x1f%cn%x1f%ce', ref
118
+ ], { allowFailure: true }));
119
+ if (!commit) return null;
120
+
121
+ let firstCommit = parseFirstCommit(await git(folder, [
122
+ 'log', '--reverse', '--format=%ct%x1f%aI%x1f%an%x1f%ae', '-1', ref
123
+ ], { allowFailure: true }));
124
+ let createdAtSource = 'first commit (inferred)';
125
+
126
+ if (branch.localRef) {
127
+ const reflog = await git(folder, ['reflog', 'show', '--format=%ct%x1f%cI%x1f%gs', branch.localRef], { allowFailure: true });
128
+ const earliestEntry = reflog.split(/\r?\n/).filter(Boolean).at(-1);
129
+ if (earliestEntry) {
130
+ const [timestamp, date] = earliestEntry.split('\x1f');
131
+ const numericTimestamp = Number(timestamp);
132
+ if (Number.isFinite(numericTimestamp)) {
133
+ firstCommit = { ...firstCommit, createdAt: new Date(numericTimestamp * 1000).toISOString() };
134
+ createdAtSource = 'local reflog';
135
+ } else if (date) {
136
+ firstCommit = { ...firstCommit, createdAt: date };
137
+ createdAtSource = 'local reflog';
138
+ }
139
+ }
140
+ }
141
+
142
+ const branchCount = Number(await git(folder, ['rev-list', '--count', ref], { allowFailure: true })) || 0;
143
+ const containingRefs = parseRefList(await git(folder, [
144
+ 'for-each-ref', '--contains', commit.hash, '--format=%(refname) %(objectname)', 'refs/heads', 'refs/remotes'
145
+ ], { allowFailure: true }));
146
+ const containingNames = [...new Set(containingRefs.map((item) => item.name).filter((name) => name !== branch.name))];
147
+ const protectedContaining = containingNames.filter(isProtectedBranch);
148
+ // A child branch containing main's commit is not evidence that main was
149
+ // merged into that child. Only protected/default targets count as merge
150
+ // evidence for cleanup decisions in this local-only phase.
151
+ const mergedInto = protectedContaining[0] || null;
152
+ const merged = Boolean(mergedInto);
153
+
154
+ let ahead = 0;
155
+ let behind = 0;
156
+ if (targetRef && ref !== targetRef) {
157
+ const counts = (await git(folder, ['rev-list', '--left-right', '--count', `${targetRef}...${ref}`], { allowFailure: true })).split(/\s+/).map(Number);
158
+ behind = Number.isFinite(counts[0]) ? counts[0] : 0;
159
+ ahead = Number.isFinite(counts[1]) ? counts[1] : 0;
160
+ }
161
+
162
+ const ageDays = dateAgeDays(commit.committedAt || commit.authoredAt);
163
+ const isCurrent = !branch.remote && currentBranch === branch.name;
164
+ const isProtected = isProtectedBranch(branch.name);
165
+ const health = healthForBranch({ merged, ahead, ageDays, isCurrent, isProtected, hasRemote: Boolean(branch.remoteRef) });
166
+
167
+ return {
168
+ id: branch.name,
169
+ name: branch.name,
170
+ source: branch.localRef && branch.remoteRef ? 'local + remote' : branch.localRef ? 'local' : 'remote',
171
+ hasLocal: Boolean(branch.localRef),
172
+ hasRemote: Boolean(branch.remoteRef),
173
+ current: isCurrent,
174
+ protected: isProtected,
175
+ createdAt: firstCommit?.createdAt || null,
176
+ createdAtSource,
177
+ creator: firstCommit?.creator || { name: '', email: '' },
178
+ lastCommit: commit,
179
+ ageDays,
180
+ commitCount: branchCount,
181
+ ahead,
182
+ behind,
183
+ merged,
184
+ mergedInto,
185
+ mergedIntoAll: protectedContaining,
186
+ mergeSource: merged ? 'local ancestry (inferred)' : 'not found in local refs',
187
+ openPullRequest: null,
188
+ health,
189
+ reasons: [
190
+ merged ? `Merged into ${mergedInto}` : 'No containing target branch found',
191
+ ageDays >= 90 ? `${ageDays} days since last commit` : null,
192
+ ahead > 0 ? `${ahead} commits not in the default branch` : null,
193
+ isCurrent ? 'Currently checked out' : null,
194
+ isProtected ? 'Matches a protected branch pattern' : null
195
+ ].filter(Boolean),
196
+ refs: { local: branch.localRef || null, remote: branch.remoteRef || null }
197
+ };
198
+ }
199
+
200
+ async function scanRepository(folder) {
201
+ const root = resolve(folder);
202
+ if (!existsSync(join(root, '.git'))) throw new Error(`Not a Git repository: ${root}`);
203
+
204
+ const [currentBranch, remoteUrl, refsRaw] = await Promise.all([
205
+ git(root, ['branch', '--show-current'], { allowFailure: true }),
206
+ git(root, ['remote', 'get-url', 'origin'], { allowFailure: true }),
207
+ git(root, ['for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads', 'refs/remotes'])
208
+ ]);
209
+ const refs = parseRefList(refsRaw);
210
+
211
+ // Remote refs are normalized to their branch name so local and origin/main
212
+ // are presented as one row when both are available.
213
+ const normalized = new Map();
214
+ refs.forEach((item) => {
215
+ const name = item.name;
216
+ const branch = normalized.get(name) || { name, localRef: null, remoteRef: null };
217
+ if (item.remote && item.remoteName === 'origin') branch.remoteRef = item.ref;
218
+ if (!item.remote) branch.localRef = item.ref;
219
+ normalized.set(name, branch);
220
+ });
221
+
222
+ const preferredTargets = ['main', 'master', 'develop', 'development', 'trunk'];
223
+ const targetName = preferredTargets.find((name) => normalized.has(name)) || [...normalized.keys()].find(isProtectedBranch) || null;
224
+ const targetBranch = targetName ? normalized.get(targetName) : null;
225
+ const targetRef = targetBranch?.localRef || targetBranch?.remoteRef || null;
226
+ const details = [];
227
+ for (const branch of normalized.values()) {
228
+ const detail = await readBranchDetails(root, branch, refs, targetRef, currentBranch);
229
+ if (detail) details.push(detail);
230
+ }
231
+
232
+ details.sort((a, b) => b.health.score - a.health.score || (b.ageDays || 0) - (a.ageDays || 0) || a.name.localeCompare(b.name));
233
+ return {
234
+ repository: { name: repositoryName(root), path: root, remoteUrl: remoteUrl || null, currentBranch: currentBranch || null },
235
+ defaultBranch: targetName,
236
+ branches: details,
237
+ scannedAt: new Date().toISOString(),
238
+ analysis: {
239
+ source: 'local Git',
240
+ mergeEvidence: 'Local ref ancestry; remote PR history is not queried in this phase.',
241
+ creationEvidence: 'Local reflog when available, otherwise first commit date.'
242
+ }
243
+ };
244
+ }
245
+
246
+ router.get('/repositories', (req, res) => {
247
+ try {
248
+ const workspaceDir = String(req.query.workspaceDir || '').trim();
249
+ if (!workspaceDir) return res.status(400).json({ error: 'Workspace directory is required.', repositories: [] });
250
+ res.json({ workspaceDir: resolve(workspaceDir), repositories: workspaceRepositoryCandidates(workspaceDir) });
251
+ } catch (error) {
252
+ res.status(400).json({ error: error.message, repositories: [] });
253
+ }
254
+ });
255
+
256
+ router.get('/history', (_req, res) => {
257
+ res.json({ history: listBranchHealthHistory().slice().reverse() });
258
+ });
259
+
260
+ function deletionChecks(branch) {
261
+ const blockers = [];
262
+ if (!branch.hasLocal && !branch.hasRemote) blockers.push('Branch no longer exists in local or origin refs.');
263
+ if (branch.current) blockers.push('The current branch cannot be deleted.');
264
+ if (branch.protected) blockers.push('Protected branch patterns cannot be deleted.');
265
+ if (!branch.merged) blockers.push('The branch head is not contained in another local ref.');
266
+ if (branch.ahead > 0) blockers.push(`${branch.ahead} commit(s) are not in the default branch.`);
267
+
268
+ return {
269
+ hasLocal: branch.hasLocal,
270
+ hasRemote: branch.hasRemote,
271
+ current: branch.current,
272
+ protected: branch.protected,
273
+ merged: branch.merged,
274
+ ahead: branch.ahead,
275
+ localDeletionAllowed: branch.hasLocal && blockers.length === 0,
276
+ remoteDeletionAllowed: branch.hasRemote && blockers.length === 0,
277
+ blockers
278
+ };
279
+ }
280
+
281
+ router.post('/preflight', async (req, res) => {
282
+ try {
283
+ const folder = String(req.body?.folder || '').trim();
284
+ const branchName = String(req.body?.branch || '').trim();
285
+ if (!folder || !branchName) return res.status(400).json({ error: 'Repository folder and branch are required.' });
286
+ const result = await scanRepository(folder);
287
+ const branch = result.branches.find((item) => item.name === branchName);
288
+ if (!branch) return res.status(404).json({ error: `Branch not found: ${branchName}` });
289
+ const checks = deletionChecks(branch);
290
+ res.json({ branch, checks, canDelete: checks.localDeletionAllowed || checks.remoteDeletionAllowed });
291
+ } catch (error) {
292
+ res.status(400).json({ error: error.message || 'Unable to preflight branch deletion.' });
293
+ }
294
+ });
295
+
296
+ router.post('/delete', async (req, res) => {
297
+ try {
298
+ const folder = String(req.body?.folder || '').trim();
299
+ const branchName = String(req.body?.branch || '').trim();
300
+ const confirmName = String(req.body?.confirmName || '').trim();
301
+ const deleteLocal = req.body?.deleteLocal === true;
302
+ const deleteRemote = req.body?.deleteRemote === true;
303
+ if (!folder || !branchName) return res.status(400).json({ error: 'Repository folder and branch are required.' });
304
+ if (!deleteLocal && !deleteRemote) return res.status(400).json({ error: 'Select at least one deletion target.' });
305
+ if (confirmName !== branchName) return res.status(400).json({ error: 'Type the exact branch name to confirm deletion.' });
306
+
307
+ // Re-scan immediately before mutating refs so a stale page cannot delete a
308
+ // branch after it has changed ownership, merge state, or checkout state.
309
+ const before = await scanRepository(folder);
310
+ const branch = before.branches.find((item) => item.name === branchName);
311
+ if (!branch) return res.status(404).json({ error: `Branch not found: ${branchName}` });
312
+ const checks = deletionChecks(branch);
313
+ if (checks.blockers.length) return res.status(409).json({ error: checks.blockers.join(' '), checks, branch });
314
+ if (deleteLocal && !checks.localDeletionAllowed) return res.status(409).json({ error: 'Local branch deletion is not allowed after the latest preflight.', checks, branch });
315
+ if (deleteRemote && !checks.remoteDeletionAllowed) return res.status(409).json({ error: 'Remote branch deletion is not allowed after the latest preflight.', checks, branch });
316
+
317
+ const results = [];
318
+ if (deleteLocal) {
319
+ try {
320
+ await git(resolve(folder), ['branch', '-d', '--', branchName]);
321
+ results.push({ target: 'local', success: true });
322
+ } catch (error) {
323
+ results.push({ target: 'local', success: false, error: error.message });
324
+ }
325
+ }
326
+ if (deleteRemote) {
327
+ try {
328
+ if (!branch.hasRemote) throw new Error('Origin branch does not exist.');
329
+ await git(resolve(folder), ['push', 'origin', '--delete', branchName]);
330
+ results.push({ target: 'remote', success: true });
331
+ } catch (error) {
332
+ results.push({ target: 'remote', success: false, error: error.message });
333
+ }
334
+ }
335
+
336
+ const success = results.length > 0 && results.every((item) => item.success);
337
+ const successfulTargets = results.filter((item) => item.success).map((item) => item.target);
338
+ const historyRecord = addBranchHealthHistory({
339
+ repository: before.repository,
340
+ branch: branchName,
341
+ targets: successfulTargets,
342
+ status: success ? 'success' : 'partial'
343
+ });
344
+ const after = await scanRepository(folder);
345
+ res.status(success ? 200 : 207).json({ success, branch: branchName, results, historyRecord, ...after });
346
+ } catch (error) {
347
+ res.status(400).json({ error: error.message || 'Unable to delete branch.' });
348
+ }
349
+ });
350
+
351
+ router.post('/scan', async (req, res) => {
352
+ try {
353
+ const folder = String(req.body?.folder || '').trim();
354
+ if (!folder) return res.status(400).json({ error: 'Repository folder is required.' });
355
+ res.json(await scanRepository(folder));
356
+ } catch (error) {
357
+ res.status(400).json({ error: error.message || 'Unable to scan repository branches.' });
358
+ }
359
+ });
360
+
361
+ export { deletionChecks, scanRepository };
362
+ export default router;
@@ -102,6 +102,14 @@ router.post('/open-folder', (req, res) => {
102
102
  openInSystemFolder(folderPath);
103
103
  res.json({ success: true });
104
104
  });
105
+ router.post('/reveal-folder', (req, res) => {
106
+ const { path } = req.body || {};
107
+ const targetPath = typeof path === 'string' ? path.trim() : '';
108
+ if (!targetPath) return res.status(400).json({ error: 'Path must be a non-empty string.' });
109
+ if (!existsSync(targetPath)) return res.status(404).json({ error: 'Path does not exist.' });
110
+ revealInSystemFolder(targetPath);
111
+ res.json({ success: true });
112
+ });
105
113
  router.post('/open-data-dir', (req, res) => {
106
114
  const targetPath = paths.dataDir;
107
115
  if (!existsSync(targetPath)) {
@@ -117,6 +117,22 @@ async function runNvm(script, timeout = 5000) {
117
117
  throw new Error('NVM is not installed or could not be loaded from the shell.');
118
118
  }
119
119
 
120
+ async function getGlobalNpmRoot() {
121
+ try { return await run('npm', ['root', '--global'], 10000); }
122
+ catch {
123
+ try { return await runNvm('npm root --global', 10000); }
124
+ catch { return ''; }
125
+ }
126
+ }
127
+
128
+ function globalPackageEntries(packageJson, globalRoot) {
129
+ return Object.entries(packageJson.dependencies || {}).map(([name, info]) => ({
130
+ name,
131
+ version: info.version || '',
132
+ directory: globalRoot ? join(globalRoot, name) : ''
133
+ }));
134
+ }
135
+
120
136
  function parseNvmVersions(output) {
121
137
  return [...new Set(String(output).split('\n').map((line) => line.match(/v\d+(?:\.\d+){0,2}/)?.[0]).filter(Boolean))];
122
138
  }
@@ -222,19 +238,21 @@ async function readGitConfig(key) {
222
238
  }
223
239
 
224
240
  async function readGlobalNpmPackages() {
241
+ const globalRoot = await getGlobalNpmRoot();
225
242
  try {
226
243
  const stdout = await run('npm', ['ls', '--global', '--depth=0', '--json'], 10000);
227
244
  const packageJson = parseJsonOutput(stdout);
228
- return Object.entries(packageJson.dependencies || {}).map(([name, info]) => ({ name, version: info.version || '' }));
245
+ return globalPackageEntries(packageJson, globalRoot);
229
246
  } catch (error) {
230
247
  try {
231
248
  const packageJson = parseJsonOutput(error.stdout || '');
232
- return Object.entries(packageJson.dependencies || {}).map(([name, info]) => ({ name, version: info.version || '' }));
249
+ return globalPackageEntries(packageJson, globalRoot);
233
250
  } catch {
234
251
  try {
235
252
  const output = await runNvm('npm ls --global --depth=0 --json', 10000);
236
253
  const packageJson = parseJsonOutput(output);
237
- return Object.entries(packageJson.dependencies || {}).map(([name, info]) => ({ name, version: info.version || '' }));
254
+ const nvmRoot = await runNvm('npm root --global', 10000).catch(() => globalRoot);
255
+ return globalPackageEntries(packageJson, nvmRoot);
238
256
  } catch { return []; }
239
257
  }
240
258
  }
@@ -14,6 +14,7 @@ const launchHistory = [];
14
14
  const savedPortHistory = new Map(listPortHistory().map((record) => [record.port, record]));
15
15
  const supervisorPath = fileURLToPath(new URL('./script-supervisor.js', import.meta.url));
16
16
  const execFileAsync = promisify(execFile);
17
+ const PROCESS_INSPECTION_TIMEOUT = 2500;
17
18
  const keyFor = (launcherId, scriptId) => `${launcherId}:${scriptId}`;
18
19
  export function countErrorBlocks(text, isStderr = false) {
19
20
  if (!text) return 0;
@@ -130,7 +131,7 @@ async function listeningProcesses() {
130
131
  }
131
132
  }
132
133
  try {
133
- const { stdout } = await execFileAsync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn'], { maxBuffer: 1024 * 1024 });
134
+ const { stdout } = await execFileAsync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn'], { maxBuffer: 1024 * 1024, timeout: PROCESS_INSPECTION_TIMEOUT });
134
135
  const rows = [];
135
136
  let current = {};
136
137
  for (const line of stdout.split('\n')) {
@@ -145,7 +146,7 @@ async function listeningProcesses() {
145
146
  }
146
147
  return rows.filter((item) => item.pid && item.port);
147
148
  } catch (error) {
148
- if (error.code === 1) return [];
149
+ if (error.code === 1 || ['ETIMEDOUT', 'EPERM', 'EACCES'].includes(error.code) || error.killed) return [];
149
150
  throw new Error('Unable to inspect listening ports. Ensure lsof is available.');
150
151
  }
151
152
  }
@@ -155,7 +156,7 @@ async function processGroupFor(pid) {
155
156
  // on some installations that resolves through a visible shell window.
156
157
  if (process.platform === 'win32') return null;
157
158
  try {
158
- const { stdout } = await execFileAsync('ps', ['-o', 'pgid=', '-p', String(pid)]);
159
+ const { stdout } = await execFileAsync('ps', ['-o', 'pgid=', '-p', String(pid)], { timeout: PROCESS_INSPECTION_TIMEOUT });
159
160
  return Number(stdout.trim());
160
161
  } catch { return null; }
161
162
  }
package/server.js CHANGED
@@ -23,6 +23,7 @@ import postmanRoutes from './server/routes/postman.js';
23
23
  import bookmarkSyncRoutes from './server/routes/bookmark-sync.js';
24
24
  import fileOrganizerRoutes from './server/routes/file-organizer.js';
25
25
  import branchSyncRoutes from './server/routes/branch-sync.js';
26
+ import branchHealthRoutes from './server/routes/branch-health.js';
26
27
  import updateRoutes from './server/routes/updates.js';
27
28
  import dataBackupRoutes from './server/routes/data-backup.js';
28
29
  import presentationsRoutes from './server/routes/presentations.js';
@@ -101,6 +102,7 @@ app.use('/api/postman', postmanRoutes);
101
102
  app.use('/api/bookmark-sync', bookmarkSyncRoutes);
102
103
  app.use('/api/file-organizer', fileOrganizerRoutes);
103
104
  app.use('/api/branch-sync', branchSyncRoutes);
105
+ app.use('/api/branch-health', branchHealthRoutes);
104
106
  app.use('/api/updates', updateRoutes);
105
107
  app.use('/api/data-backup', dataBackupRoutes);
106
108
  app.use('/api/presentations', presentationsRoutes);