buddy-workbench 0.1.71 → 0.1.73

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.71",
3
+ "version": "0.1.73",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,5 +59,8 @@
59
59
  "@jsquash/jpeg": "1.6.0",
60
60
  "axios": "1.7.9",
61
61
  "express": "5.1.0"
62
+ },
63
+ "overrides": {
64
+ "content-type": "2.0.0"
62
65
  }
63
66
  }
@@ -3,8 +3,6 @@ import axios from 'axios';
3
3
  import { Router } from 'express';
4
4
  import { readSettings } from '../repositories/settings.js';
5
5
  import { listTodos } from '../repositories/todos.js';
6
- import { listBranchSyncApps } from '../repositories/branch-sync.js';
7
- import { getPresentations } from '../repositories/presentations.js';
8
6
  import { recordApiError } from '../lib/api-errors.js';
9
7
 
10
8
  const router = Router();
@@ -16,16 +14,6 @@ const httpClient = axios.create({
16
14
 
17
15
  const isSuccess = (response) => response.status >= 200 && response.status < 300;
18
16
  const apiMessage = (response, fallback) => response?.data?.errors?.[0]?.message || response?.data?.errorMessages?.[0] || response?.data?.message || response?.statusText || fallback;
19
- const getPresentationInsights = () => getPresentations().plans
20
- .slice()
21
- .sort((a, b) => Number(b.updatedAt || b.createdAt || 0) - Number(a.updatedAt || a.createdAt || 0))
22
- .map((plan) => ({
23
- id: plan.id,
24
- title: plan.title || plan.name || 'Untitled presentation',
25
- summary: `${Array.isArray(plan.sections) ? plan.sections.length : 0} sections`,
26
- updated: plan.updatedAt || plan.createdAt || null,
27
- url: '#/presentation-plan'
28
- }));
29
17
 
30
18
  function jiraHost(domain) {
31
19
  const clean = String(domain || '').replace(/^https?:\/\//i, '').replace(/\/+$/, '');
@@ -39,20 +27,7 @@ function bitbucketHost(domain) {
39
27
  return clean.includes('bitbucket') ? clean : `bitbucket.${clean}`;
40
28
  }
41
29
 
42
- function parseRepo(repo) {
43
- const raw = String(repo || '').replace(/\.git$/i, '');
44
- try {
45
- const url = new URL(raw.startsWith('ssh://') ? raw.replace(/^ssh:\/\//i, 'https://') : raw);
46
- const parts = url.pathname.split('/').filter(Boolean);
47
- if (parts[0] === 'scm' && parts.length >= 3) return { host: url.host, project: parts[1], slug: parts[2] };
48
- if ((parts[0] === 'projects' || parts[0] === 'users') && parts.length >= 4) return { host: url.host, project: parts[0] === 'users' ? `~${parts[1]}` : parts[1], slug: parts[3] };
49
- if (parts.length >= 2) return { host: url.host, project: parts.at(-2), slug: parts.at(-1) };
50
- } catch {}
51
- return null;
52
- }
53
-
54
30
  function jiraUrl(host, path) { return `https://${host}/rest/api/2${path}`; }
55
- function bitbucketUrl(host, path) { return `https://${host}/rest/api/latest${path}`; }
56
31
 
57
32
  async function requestSection(name, loader) {
58
33
  try {
@@ -95,10 +70,9 @@ async function loadJiraData(host, token) {
95
70
  const identity = { accountId: me.accountId || '', name: me.name || '', displayName: me.displayName || '' };
96
71
  const fields = 'summary,status,priority,duedate,updated,assignee,reporter';
97
72
 
98
- const [due, activity, blocked] = await Promise.all([
73
+ const [due, activity] = await Promise.all([
99
74
  searchJira(host, token, 'assignee = currentUser() AND due = startOfDay() AND resolution = Unresolved ORDER BY priority DESC', fields),
100
- searchJira(host, token, 'updated >= -14d AND resolution = Unresolved AND (assignee = currentUser() OR reporter = currentUser() OR watcher = currentUser()) ORDER BY updated DESC', fields, 30),
101
- searchJira(host, token, 'status = Blocked AND updated <= -3d AND resolution = Unresolved ORDER BY updated ASC', fields)
75
+ searchJira(host, token, 'updated >= -14d AND resolution = Unresolved AND (assignee = currentUser() OR reporter = currentUser() OR watcher = currentUser()) ORDER BY updated DESC', fields, 30)
102
76
  ]);
103
77
 
104
78
  const activityItems = [];
@@ -130,15 +104,14 @@ async function loadJiraData(host, token) {
130
104
  return {
131
105
  identity,
132
106
  dueToday: due.map((issue) => ({ ...mapJiraIssue(issue), url: issueUrl(host, issue.key) })),
133
- activity: activityItems.map((item) => ({ ...item, url: issueUrl(host, item.key) })),
134
- blocked: blocked.map((issue) => ({ ...mapJiraIssue(issue), url: issueUrl(host, issue.key) }))
107
+ activity: activityItems.map((item) => ({ ...item, url: issueUrl(host, item.key) }))
135
108
  };
136
109
  }
137
110
 
138
- async function loadPullRequests(host, token, role) {
139
- const url = `https://${host}/rest/api/latest/dashboard/pull-requests?role=${role}&state=OPEN&limit=100`;
111
+ async function loadAuthoredPullRequests(host, token) {
112
+ const url = `https://${host}/rest/api/latest/dashboard/pull-requests?role=author&state=OPEN&limit=100`;
140
113
  const response = await httpClient.get(url, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } });
141
- if (!isSuccess(response)) throw new Error(`Bitbucket ${role} pull requests failed (${response.status}): ${apiMessage(response, 'Request failed')}`);
114
+ if (!isSuccess(response)) throw new Error(`Bitbucket author pull requests failed (${response.status}): ${apiMessage(response, 'Request failed')}`);
142
115
  return (response.data?.values || []).map((pr) => ({
143
116
  id: pr.id,
144
117
  title: pr.title || `Pull request #${pr.id}`,
@@ -149,65 +122,21 @@ async function loadPullRequests(host, token, role) {
149
122
  }));
150
123
  }
151
124
 
152
- async function loadFailedBuilds(host, token) {
153
- const settings = readSettings();
154
- const apps = listBranchSyncApps();
155
- const repos = [];
156
- for (const app of apps) {
157
- for (const branch of [...new Set([app.activeBranch, app.lastReleaseBranch, 'master'].filter(Boolean))]) {
158
- repos.push({ name: app.name, repo: app.repo, branch });
159
- }
160
- for (const sub of app.subApps || []) {
161
- for (const branch of [...new Set([sub.branch, app.activeBranch, 'master'].filter(Boolean))]) {
162
- repos.push({ name: sub.name || sub.directory, repo: sub.repo, branch });
163
- }
164
- }
165
- }
166
- const unique = [...new Map(repos.map((item) => [`${item.repo}|${item.branch}`, item])).values()];
167
- const failed = [];
168
- for (const item of unique.slice(0, 30)) {
169
- const parsed = parseRepo(item.repo);
170
- if (!parsed || (parsed.host && parsed.host !== host && !parsed.host.includes('bitbucket'))) continue;
171
- const commitUrl = bitbucketUrl(parsed.host || host, `/projects/${encodeURIComponent(parsed.project)}/repos/${encodeURIComponent(parsed.slug)}/commits?until=${encodeURIComponent(item.branch)}&limit=1`);
172
- const commitResponse = await httpClient.get(commitUrl, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } });
173
- if (!isSuccess(commitResponse)) {
174
- recordApiError({ source: 'Overview (Bitbucket Builds)', method: 'GET', url: commitUrl, status: commitResponse.status, message: apiMessage(commitResponse, 'Failed to fetch branch commit.') });
175
- continue;
176
- }
177
- const hash = commitResponse.data?.values?.[0]?.id;
178
- if (!hash) continue;
179
- const buildUrl = `https://${parsed.host || host}/rest/api/latest/projects/${encodeURIComponent(parsed.project)}/repos/${encodeURIComponent(parsed.slug)}/commits/${encodeURIComponent(hash)}/builds`;
180
- const buildResponse = await httpClient.get(buildUrl, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } });
181
- if (!isSuccess(buildResponse)) {
182
- if (buildResponse.status !== 404) recordApiError({ source: 'Overview (Bitbucket Builds)', method: 'GET', url: buildUrl, status: buildResponse.status, message: apiMessage(buildResponse, 'Failed to fetch build status.') });
183
- continue;
184
- }
185
- for (const build of buildResponse.data?.values || []) {
186
- if (String(build.state || '').toUpperCase() === 'FAILED') {
187
- failed.push({ name: item.name, branch: item.branch, repository: item.repo, build: build.name || build.key || 'Build', date: build.dateAdded || null, url: item.repo });
188
- }
189
- }
190
- }
191
- return failed;
192
- }
193
-
194
125
  router.get('/', async (_req, res) => {
195
126
  const settings = readSettings();
196
- const missingTokens = ['jira', 'bitbucket', 'confluence'].filter((service) => !settings[`${service}AccessToken`]);
127
+ const missingTokens = ['jira', 'bitbucket'].filter((service) => !settings[`${service}AccessToken`]);
197
128
  if (missingTokens.length > 0) {
198
- return res.json({ configured: false, missingTokens, fetchedAt: new Date().toISOString(), categories: { presentations: getPresentationInsights() } });
129
+ return res.json({ configured: false, missingTokens, fetchedAt: new Date().toISOString(), categories: {} });
199
130
  }
200
131
  const jira = jiraHost(settings.domain);
201
132
  const bitbucket = bitbucketHost(settings.domain);
202
- if (!jira || !bitbucket) return res.json({ configured: false, missingTokens: ['domain'], fetchedAt: new Date().toISOString(), categories: { presentations: getPresentationInsights() } });
133
+ if (!jira || !bitbucket) return res.json({ configured: false, missingTokens: ['domain'], fetchedAt: new Date().toISOString(), categories: {} });
203
134
 
204
135
  const sections = await Promise.all([
205
136
  requestSection('Jira', () => loadJiraData(jira, settings.jiraAccessToken)),
206
- requestSection('Bitbucket Review', () => loadPullRequests(bitbucket, settings.bitbucketAccessToken, 'reviewer')),
207
- requestSection('Bitbucket Author', () => loadPullRequests(bitbucket, settings.bitbucketAccessToken, 'author')),
208
- requestSection('Bitbucket Builds', () => loadFailedBuilds(bitbucket, settings.bitbucketAccessToken))
137
+ requestSection('Bitbucket Author', () => loadAuthoredPullRequests(bitbucket, settings.bitbucketAccessToken))
209
138
  ]);
210
- const [jiraData, reviewPrs, authoredPrs, failedBuilds] = sections;
139
+ const [jiraData, authoredPrs] = sections;
211
140
  const todos = listTodos().filter((todo) => !todo.completed && !todo.archived).map((todo) => ({ ...todo, url: '#/todo-list' }));
212
141
  const errors = sections.filter((section) => section.error).map((section) => section.error);
213
142
  res.json({
@@ -215,14 +144,10 @@ router.get('/', async (_req, res) => {
215
144
  missingTokens: [],
216
145
  fetchedAt: new Date().toISOString(),
217
146
  categories: {
147
+ activity: jiraData.items?.activity || [],
218
148
  jiraDueToday: jiraData.items?.dueToday || [],
219
149
  assignedTodos: todos,
220
- reviewPrs: reviewPrs.items || [],
221
- authoredPrs: authoredPrs.items || [],
222
- failedBuilds: failedBuilds.items || [],
223
- activity: jiraData.items?.activity || [],
224
- blockedIssues: jiraData.items?.blocked || [],
225
- presentations: getPresentationInsights()
150
+ authoredPrs: authoredPrs.items || []
226
151
  },
227
152
  errors
228
153
  });
@@ -1,12 +1,25 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { basename, dirname, join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
 
7
7
  const execFileAsync = promisify(execFile);
8
8
  const NPM_KEYS = ['prefix', 'registry', 'strict-ssl', 'cache', 'proxy', 'https-proxy'];
9
- const NVM_LOAD = 'if [ -s "$NVM_DIR/nvm.sh" ]; then . "$NVM_DIR/nvm.sh"; elif [ -s "$HOME/.nvm/nvm.sh" ]; then . "$HOME/.nvm/nvm.sh"; fi; ';
9
+
10
+ function getNvmDir() {
11
+ const home = homedir();
12
+ const candidates = [
13
+ process.env.NVM_DIR,
14
+ join(home, '.nvm'),
15
+ '/opt/homebrew/opt/nvm',
16
+ '/usr/local/opt/nvm'
17
+ ].filter(Boolean);
18
+ for (const dir of candidates) {
19
+ if (existsSync(join(dir, 'nvm.sh'))) return dir;
20
+ }
21
+ return process.env.NVM_DIR || join(home, '.nvm');
22
+ }
10
23
 
11
24
  function getEnrichedEnv() {
12
25
  const home = homedir();
@@ -22,7 +35,7 @@ function getEnrichedEnv() {
22
35
  '/usr/sbin',
23
36
  '/sbin'
24
37
  ];
25
- const nvmDir = process.env.NVM_DIR || join(home, '.nvm');
38
+ const nvmDir = getNvmDir();
26
39
  if (existsSync(nvmDir)) {
27
40
  try {
28
41
  const versionsDir = join(nvmDir, 'versions', 'node');
@@ -86,9 +99,18 @@ async function runNvm(script, timeout = 5000) {
86
99
  if (process.platform === 'win32') {
87
100
  throw new Error('Unix NVM shell is not supported on Windows.');
88
101
  }
89
- for (const shell of ['zsh', 'bash']) {
102
+ const nvmDir = getNvmDir();
103
+ const nvmSh = join(nvmDir, 'nvm.sh');
104
+ const nvmLoad = existsSync(nvmSh) ? `. "${nvmSh}"; ` : '';
105
+ const fullScript = `${nvmLoad}${script}`;
106
+ for (const shell of ['bash', 'sh', 'zsh']) {
90
107
  try {
91
- const { stdout } = await execFileAsync(shell, ['-ilc', `${NVM_LOAD}${script}`], { timeout, maxBuffer: 4 * 1024 * 1024, env: getEnrichedEnv(), windowsHide: false });
108
+ const { stdout } = await execFileAsync(shell, ['-c', fullScript], {
109
+ timeout,
110
+ maxBuffer: 4 * 1024 * 1024,
111
+ env: { ...getEnrichedEnv(), NVM_DIR: nvmDir },
112
+ windowsHide: false
113
+ });
92
114
  return stdout.trim();
93
115
  } catch {}
94
116
  }
@@ -100,22 +122,90 @@ function parseNvmVersions(output) {
100
122
  }
101
123
 
102
124
  export async function readNvmConfiguration() {
103
- try {
104
- const output = await runNvm('if ! command -v nvm >/dev/null 2>&1; then exit 1; fi; printf "__VERSION__%s\\n" "$(nvm --version)"; printf "__CURRENT__%s\\n" "$(nvm current)"; printf "__DEFAULT__%s\\n" "$(nvm alias default 2>/dev/null)"; printf "__DIR__%s\\n" "${NVM_DIR:-$HOME/.nvm}"; printf "__MIRROR__%s\\n" "${NVM_NODEJS_ORG_MIRROR:-}"; printf "__LIST__\\n"; nvm ls --no-colors');
105
- const value = (key) => output.match(new RegExp(`__${key}__(.*)`))?.[1]?.trim() || '';
106
- const defaultLine = value('DEFAULT');
125
+ const nvmDir = getNvmDir();
126
+ const nvmSh = join(nvmDir, 'nvm.sh');
127
+ if (!existsSync(nvmSh)) {
107
128
  return {
108
- installed: true,
109
- version: value('VERSION'),
110
- currentVersion: value('CURRENT'),
111
- defaultVersion: defaultLine.match(/->\s+(?:v)?(\d+(?:\.\d+){0,2}|node|lts\/[\w.-]+)/)?.[1] || '',
112
- nvmDir: value('DIR'),
113
- nodeJsMirror: value('MIRROR'),
114
- installedVersions: parseNvmVersions(output.split('__LIST__')[1] || '')
129
+ installed: false,
130
+ version: '',
131
+ currentVersion: '',
132
+ defaultVersion: '',
133
+ nvmDir: '',
134
+ nodeJsMirror: '',
135
+ installedVersions: []
115
136
  };
116
- } catch {
117
- return { installed: false, version: '', currentVersion: '', defaultVersion: '', nvmDir: '', nodeJsMirror: '', installedVersions: [] };
118
137
  }
138
+
139
+ let version = '';
140
+ try {
141
+ const pkgFile = join(nvmDir, 'package.json');
142
+ if (existsSync(pkgFile)) {
143
+ const pkg = JSON.parse(readFileSync(pkgFile, 'utf8'));
144
+ version = pkg.version || '';
145
+ }
146
+ } catch {}
147
+
148
+ const installedVersions = [];
149
+ const versionsDir = join(nvmDir, 'versions', 'node');
150
+ if (existsSync(versionsDir)) {
151
+ try {
152
+ for (const entry of readdirSync(versionsDir)) {
153
+ if (/^v?\d+/.test(entry)) {
154
+ installedVersions.push(entry.startsWith('v') ? entry : `v${entry}`);
155
+ }
156
+ }
157
+ } catch {}
158
+ }
159
+ try {
160
+ for (const entry of readdirSync(nvmDir)) {
161
+ if (/^v\d+\.\d+\.\d+$/.test(entry) && existsSync(join(nvmDir, entry, 'bin', 'node'))) {
162
+ if (!installedVersions.includes(entry)) installedVersions.push(entry);
163
+ }
164
+ }
165
+ } catch {}
166
+ installedVersions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }));
167
+
168
+ let defaultVersion = '';
169
+ try {
170
+ const defaultAliasFile = join(nvmDir, 'alias', 'default');
171
+ if (existsSync(defaultAliasFile)) {
172
+ defaultVersion = readFileSync(defaultAliasFile, 'utf8').trim();
173
+ }
174
+ } catch {}
175
+
176
+ let currentVersion = process.version || '';
177
+ try {
178
+ const currentLink = join(nvmDir, 'current');
179
+ if (existsSync(currentLink)) {
180
+ const real = basename(realpathSync(currentLink));
181
+ if (/^v?\d+/.test(real)) {
182
+ currentVersion = real.startsWith('v') ? real : `v${real}`;
183
+ }
184
+ }
185
+ } catch {}
186
+
187
+ let nodeJsMirror = process.env.NVM_NODEJS_ORG_MIRROR || '';
188
+ if (!nodeJsMirror) {
189
+ try {
190
+ const shellFile = basename(process.env.SHELL || '') === 'bash' ? '.bashrc' : '.zshrc';
191
+ const file = join(homedir(), shellFile);
192
+ if (existsSync(file)) {
193
+ const content = readFileSync(file, 'utf8');
194
+ const match = content.match(/export\s+NVM_NODEJS_ORG_MIRROR="([^"]*)"/);
195
+ if (match) nodeJsMirror = match[1];
196
+ }
197
+ } catch {}
198
+ }
199
+
200
+ return {
201
+ installed: true,
202
+ version,
203
+ currentVersion,
204
+ defaultVersion,
205
+ nvmDir,
206
+ nodeJsMirror,
207
+ installedVersions
208
+ };
119
209
  }
120
210
 
121
211
  async function readNpmConfig(key) {