buddy-workbench 0.1.47 → 0.1.49

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.47",
3
+ "version": "0.1.49",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,6 +4,7 @@ import { Router } from 'express';
4
4
  import { readSettings } from '../repositories/settings.js';
5
5
  import { listTodos } from '../repositories/todos.js';
6
6
  import { listBranchSyncApps } from '../repositories/branch-sync.js';
7
+ import { getPresentations } from '../repositories/presentations.js';
7
8
  import { recordApiError } from '../lib/api-errors.js';
8
9
 
9
10
  const router = Router();
@@ -15,6 +16,16 @@ const httpClient = axios.create({
15
16
 
16
17
  const isSuccess = (response) => response.status >= 200 && response.status < 300;
17
18
  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
+ }));
18
29
 
19
30
  function jiraHost(domain) {
20
31
  const clean = String(domain || '').replace(/^https?:\/\//i, '').replace(/\/+$/, '');
@@ -184,11 +195,11 @@ router.get('/', async (_req, res) => {
184
195
  const settings = readSettings();
185
196
  const missingTokens = ['jira', 'bitbucket', 'confluence'].filter((service) => !settings[`${service}AccessToken`]);
186
197
  if (missingTokens.length > 0) {
187
- return res.json({ configured: false, missingTokens, fetchedAt: new Date().toISOString(), categories: {} });
198
+ return res.json({ configured: false, missingTokens, fetchedAt: new Date().toISOString(), categories: { presentations: getPresentationInsights() } });
188
199
  }
189
200
  const jira = jiraHost(settings.domain);
190
201
  const bitbucket = bitbucketHost(settings.domain);
191
- if (!jira || !bitbucket) return res.json({ configured: false, missingTokens: ['domain'], fetchedAt: new Date().toISOString(), categories: {} });
202
+ if (!jira || !bitbucket) return res.json({ configured: false, missingTokens: ['domain'], fetchedAt: new Date().toISOString(), categories: { presentations: getPresentationInsights() } });
192
203
 
193
204
  const sections = await Promise.all([
194
205
  requestSection('Jira', () => loadJiraData(jira, settings.jiraAccessToken)),
@@ -210,7 +221,8 @@ router.get('/', async (_req, res) => {
210
221
  authoredPrs: authoredPrs.items || [],
211
222
  failedBuilds: failedBuilds.items || [],
212
223
  activity: jiraData.items?.activity || [],
213
- blockedIssues: jiraData.items?.blocked || []
224
+ blockedIssues: jiraData.items?.blocked || [],
225
+ presentations: getPresentationInsights()
214
226
  },
215
227
  errors
216
228
  });
@@ -1,3 +1,4 @@
1
+ import https from 'node:https';
1
2
  import axios from 'axios';
2
3
  import { Router } from 'express';
3
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -11,6 +12,11 @@ import { recordApiError } from '../lib/api-errors.js';
11
12
  const router = Router();
12
13
  const exec = promisify(execFile);
13
14
  const running = new Set();
15
+ const httpsAgent = new https.Agent({ rejectUnauthorized: false });
16
+ const httpClient = axios.create({
17
+ httpsAgent,
18
+ validateStatus: () => true
19
+ });
14
20
 
15
21
  function repoName(url) { return basename(String(url || '').replace(/[\\/]$/, '').replace(/\.git$/i, '').replace(/[:/]+$/, '')) || 'repo'; }
16
22
  function repoPath(data, repo) { return join(data.workspaceDir, repo.directory || repoName(repo.url)); }
@@ -24,6 +30,35 @@ function parseBitbucket(url) {
24
30
  if (parts[0] === 'projects' || parts[0] === 'users') return { protocol, host: u.host, project: parts[1], slug: parts[3] };
25
31
  return parts.length >= 2 ? { protocol, host: u.host, project: parts.at(-2), slug: parts.at(-1) } : null;
26
32
  }
33
+ function bitbucketHeaders() {
34
+ const settings = readSettings();
35
+ const headers = { Accept: 'application/json' };
36
+ if (settings.bitbucketAccessToken) headers.Authorization = `Bearer ${settings.bitbucketAccessToken}`;
37
+ return headers;
38
+ }
39
+ function branchTimestamp(commit) {
40
+ const value = commit?.committerTimestamp || commit?.authorTimestamp || commit?.committer?.timestamp || commit?.author?.timestamp || commit?.date;
41
+ const timestamp = typeof value === 'number' ? value : Date.parse(value || '');
42
+ return Number.isNaN(timestamp) ? 0 : timestamp;
43
+ }
44
+ async function fetchRemoteBranches(repo) {
45
+ const parsed = parseBitbucket(repo.url);
46
+ if (!parsed) throw new Error(`Could not parse Bitbucket repository URL for ${repo.name || repoName(repo.url)}.`);
47
+ const base = `${parsed.protocol}://${parsed.host}/rest/api/latest/projects/${encodeURIComponent(parsed.project)}/repos/${encodeURIComponent(parsed.slug)}`;
48
+ const response = await httpClient.get(`${base}/branches?limit=200&orderBy=MODIFICATION`, { headers: bitbucketHeaders() });
49
+ if (response.status < 200 || response.status >= 300) throw new Error(response.data?.errors?.[0]?.message || `Bitbucket branches failed (${response.status})`);
50
+ const values = Array.isArray(response.data?.values) ? response.data.values : [];
51
+ return { base, branches: values.map((branch, index) => ({ name: branch.displayId || branch.id?.replace(/^refs\/heads\//, ''), latestCommit: branch.latestCommit, rank: index })).filter((branch) => branch.name) };
52
+ }
53
+ async function fetchBranchUpdatedAt(base, branch) {
54
+ if (!branch.latestCommit) return { name: branch.name, updatedAt: 0, rank: branch.rank };
55
+ try {
56
+ const response = await httpClient.get(`${base}/commits/${encodeURIComponent(branch.latestCommit)}`, { headers: bitbucketHeaders() });
57
+ return { name: branch.name, updatedAt: response.status >= 200 && response.status < 300 ? branchTimestamp(response.data) : 0, rank: branch.rank };
58
+ } catch {
59
+ return { name: branch.name, updatedAt: 0, rank: branch.rank };
60
+ }
61
+ }
27
62
  function updateTask(taskId, updater) {
28
63
  const data = readPackageUpgradeData(); const task = data.tasks.find((item) => item.id === taskId);
29
64
  if (!task) return;
@@ -82,7 +117,7 @@ async function runRepo(task, repo) {
82
117
  if (!parsed) throw new Error('Could not parse Bitbucket repository URL');
83
118
  const headers = { Accept: 'application/json', 'Content-Type': 'application/json' }; if (settings.bitbucketAccessToken) headers.Authorization = `Bearer ${settings.bitbucketAccessToken}`;
84
119
  const api = `${parsed.protocol}://${parsed.host}/rest/api/latest/projects/${encodeURIComponent(parsed.project)}/repos/${encodeURIComponent(parsed.slug)}/pull-requests`;
85
- const response = await axios.post(api, { title: task.commitMessage, description: `Upgrade ${task.packageName} to ${task.version}`, fromRef: { id: `refs/heads/${task.sourceBranch}` }, toRef: { id: `refs/heads/${task.targetBranch}` } }, { headers, validateStatus: () => true });
120
+ const response = await httpClient.post(api, { title: task.commitMessage, description: `Upgrade ${task.packageName} to ${task.version}`, fromRef: { id: `refs/heads/${task.sourceBranch}` }, toRef: { id: `refs/heads/${task.targetBranch}` } }, { headers, validateStatus: () => true });
86
121
  if (response.status < 200 || response.status >= 300) throw new Error(response.data?.errors?.[0]?.message || `Bitbucket PR failed (${response.status})`);
87
122
  const prUrl = response.data?.links?.self?.[0]?.href || `${parsed.protocol}://${parsed.host}/projects/${parsed.project}/repos/${parsed.slug}/pull-requests/${response.data.id}`;
88
123
  updateTask(task.id, (t) => ({ ...t, repos: t.repos.map((r) => r.repoId === repo.id ? { ...result, status: 'success', prUrl } : r) }));
@@ -117,6 +152,26 @@ router.post('/repos/packages', (req, res) => {
117
152
  });
118
153
  res.json([...packageNames].sort((a, b) => a.localeCompare(b)));
119
154
  });
155
+ router.post('/repos/branches', async (req, res) => {
156
+ const data = readPackageUpgradeData();
157
+ const repoIds = Array.isArray(req.body?.repoIds) ? req.body.repoIds : [];
158
+ const repos = repoIds.map((repoId) => data.repos.find((repo) => repo.id === repoId)).filter(Boolean);
159
+ if (!repos.length) return res.json([]);
160
+ try {
161
+ const remote = await Promise.all(repos.map((repo) => fetchRemoteBranches(repo)));
162
+ const branchSets = remote.map(({ branches }) => new Set(branches.map((branch) => branch.name)));
163
+ const commonNames = remote[0].branches.map((branch) => branch.name).filter((name) => branchSets.every((branches) => branches.has(name)));
164
+ const commonBranches = await Promise.all(commonNames.map(async (name) => {
165
+ const branches = remote.map(({ base, branches: items }) => ({ base, branch: items.find((item) => item.name === name) })).filter(({ branch }) => branch);
166
+ const updated = await Promise.all(branches.map(({ base, branch }) => fetchBranchUpdatedAt(base, branch)));
167
+ return { name, updatedAt: Math.max(...updated.map((item) => item.updatedAt)), rank: Math.min(...updated.map((item) => item.rank)) };
168
+ }));
169
+ commonBranches.sort((a, b) => b.updatedAt - a.updatedAt || a.rank - b.rank || a.name.localeCompare(b.name));
170
+ res.json(commonBranches.map(({ name, updatedAt }) => ({ name, updatedAt: updatedAt ? new Date(updatedAt).toISOString() : null })));
171
+ } catch (error) {
172
+ res.status(502).json({ error: error.message || 'Failed to fetch common repository branches.' });
173
+ }
174
+ });
120
175
  router.post('/repos/:id/clone', async (req, res) => { const data = readPackageUpgradeData(); const repo = data.repos.find((item) => item.id === req.params.id); if (!repo) return res.status(404).json({ error: 'Repository not found.' }); if (!data.workspaceDir) return res.status(400).json({ error: 'Configure a workspace directory first.' }); const folder = repoPath(data, repo); try { mkdirSync(data.workspaceDir, { recursive: true }); if (!existsSync(join(folder, '.git'))) await exec('git', ['clone', repo.url, folder], { cwd: data.workspaceDir, timeout: 20 * 60 * 1000, maxBuffer: 2 * 1024 * 1024 }); res.json({ ...repo, cloned: true }); } catch (error) { res.status(400).json({ error: error.stderr || error.message }); } });
121
176
  router.delete('/repos/:id', (req, res) => { const data = readPackageUpgradeData(); data.repos = data.repos.filter((r) => r.id !== req.params.id); savePackageUpgradeData(data); res.status(204).end(); });
122
177
  router.post('/tasks', (req, res) => { const body = req.body || {}; const required = ['sourceBranch', 'targetBranch', 'packageName', 'version', 'commitMessage']; if (required.some((key) => !String(body[key] || '').trim()) || !Array.isArray(body.repoIds) || !body.repoIds.length) return res.status(400).json({ error: 'All fields and at least one repository are required.' }); const data = readPackageUpgradeData(); if (!data.workspaceDir) return res.status(400).json({ error: 'Configure a workspace directory first.' }); const repoIds = body.repoIds.filter((id) => data.repos.some((repo) => repo.id === id)); if (!repoIds.length) return res.status(400).json({ error: 'Select at least one configured repository.' }); const task = { id: `task-${Date.now()}`, ...Object.fromEntries(required.map((key) => [key, String(body[key]).trim()])), repoIds, repos: repoIds.map((repoId) => ({ repoId, status: 'queued' })), status: 'running', createdAt: new Date().toISOString() }; data.tasks.unshift(task); savePackageUpgradeData(data); void execute(task); res.status(201).json(task); });
@@ -138,6 +138,27 @@ function getEditorUrl(editor = 'vscode', path = '') {
138
138
  }
139
139
 
140
140
  function dayItems(date) { return readJson(dayFile(date), []); }
141
+
142
+ async function getClipboardText() {
143
+ const commands = process.platform === 'darwin'
144
+ ? [['pbpaste', []]]
145
+ : process.platform === 'win32'
146
+ ? [['powershell.exe', ['-NoProfile', '-Command', 'Get-Clipboard -Format Text -Raw']]]
147
+ : [
148
+ ['wl-paste', ['--no-newline', '--type', 'text']],
149
+ ['xclip', ['-selection', 'clipboard', '-o', '-t', 'UTF8_STRING']],
150
+ ['xsel', ['--clipboard', '--output']]
151
+ ];
152
+
153
+ for (const [command, args] of commands) {
154
+ try {
155
+ const { stdout } = await execFileAsync(command, args, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });
156
+ return stdout;
157
+ } catch {}
158
+ }
159
+ return '';
160
+ }
161
+
141
162
  export function clipboardItems(date = today()) {
142
163
  const settings = readSettings();
143
164
  const editor = settings.defaultEditor || 'vscode';
@@ -208,7 +229,7 @@ export function deleteClipboardItem(date, id) {
208
229
  const contentPath = join(paths.clipboardDir, 'content', item.contentFile);
209
230
  if (existsSync(contentPath)) unlinkSync(contentPath);
210
231
  }
211
- if (item.imageFile) {
232
+ if (item.imageFile && process.platform === 'darwin') {
212
233
  const imagePath = join(paths.clipboardDir, 'content', item.imageFile);
213
234
  if (existsSync(imagePath)) unlinkSync(imagePath);
214
235
  }
@@ -216,13 +237,12 @@ export function deleteClipboardItem(date, id) {
216
237
  }
217
238
 
218
239
  export async function captureClipboard() {
219
- if (process.platform !== 'darwin') return;
220
240
  const settings = readSettings();
221
241
  if (settings.clipboardEnabled === false) return;
222
242
 
223
243
  // 1. Text capture
224
244
  try {
225
- const { stdout } = await execFileAsync('pbpaste');
245
+ const stdout = await getClipboardText();
226
246
  const text = stdout.trim();
227
247
  if (text && text !== lastValue) {
228
248
  lastValue = text;
@@ -277,7 +297,7 @@ export async function captureClipboard() {
277
297
  } catch {}
278
298
 
279
299
  // 2. Image capture (Mac only & clipboardImageEnabled !== false)
280
- if (settings.clipboardImageEnabled !== false) {
300
+ if (process.platform === 'darwin' && settings.clipboardImageEnabled !== false) {
281
301
  try {
282
302
  const imageData = await getMacClipboardImageData();
283
303
  if (imageData && imageData.rgbaBuf.length > 0) {