buddy-workbench 0.1.69 → 0.1.71

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.69",
3
+ "version": "0.1.71",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  import express, { Router } from 'express';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
- import { clipboardDates, clipboardItems, clipboardOriginal, deleteClipboardItem, saveClipboardImage, clipboardImagePath, allTaggedClipboardItems, updateClipboardItemTags, markClipboardCopied } from '../services/clipboard-history.js';
3
+ import { clipboardDates, clipboardItems, clipboardOriginal, deleteClipboardItem, saveClipboardImage, clipboardImagePath, allTaggedClipboardItems, updateClipboardItemTags, markClipboardCopied, copyClipboardImage } from '../services/clipboard-history.js';
4
4
 
5
5
  const router = Router();
6
6
  router.get('/', (req, res) => res.json({ dates: clipboardDates(), items: clipboardItems(req.query.date) }));
@@ -48,5 +48,13 @@ router.post('/:date/:id/copied', async (req, res) => {
48
48
  res.status(500).json({ error: error.message });
49
49
  }
50
50
  });
51
+ router.post('/:date/:id/copy-image', async (req, res) => {
52
+ try {
53
+ await copyClipboardImage(req.params.date, req.params.id);
54
+ res.json({ ok: true });
55
+ } catch (error) {
56
+ res.status(500).json({ error: error.message });
57
+ }
58
+ });
51
59
  router.delete('/:date/:id', (req, res) => { if (!deleteClipboardItem(req.params.date, req.params.id)) return res.status(404).json({ error: 'Clipboard entry not found.' }); res.status(204).end(); });
52
60
  export default router;
@@ -54,6 +54,131 @@ async function getJiraIssueSummary(issueKey) {
54
54
  return summary;
55
55
  }
56
56
 
57
+ function transformFieldValue(val) {
58
+ if (val === null || val === undefined) return undefined;
59
+
60
+ if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
61
+ return val;
62
+ }
63
+
64
+ if (Array.isArray(val)) {
65
+ if (val.length === 0) return undefined;
66
+ const items = val.map((item) => transformFieldValue(item)).filter((item) => item !== undefined);
67
+ return items.length > 0 ? items : undefined;
68
+ }
69
+
70
+ if (typeof val === 'object') {
71
+ const keys = Object.keys(val);
72
+ if (keys.length === 0) return undefined;
73
+
74
+ // Support cascading select (parent value + child value)
75
+ if (val.value !== undefined && val.child && typeof val.child === 'object') {
76
+ const childVal = transformFieldValue(val.child);
77
+ if (childVal) {
78
+ return { value: String(val.value), child: childVal };
79
+ }
80
+ }
81
+
82
+ if (val.id !== undefined && val.id !== null) {
83
+ return { id: String(val.id) };
84
+ }
85
+ if (val.accountId !== undefined && val.accountId !== null) {
86
+ return { accountId: String(val.accountId) };
87
+ }
88
+ if (val.value !== undefined && val.value !== null) {
89
+ return { value: String(val.value) };
90
+ }
91
+ if (val.key !== undefined && val.key !== null) {
92
+ return { key: String(val.key) };
93
+ }
94
+ if (val.name !== undefined && val.name !== null) {
95
+ return { name: String(val.name) };
96
+ }
97
+ }
98
+
99
+ return undefined;
100
+ }
101
+
102
+ function extractCloneableFields(origFields) {
103
+ if (!origFields || typeof origFields !== 'object') return {};
104
+
105
+ const fields = {};
106
+
107
+ // Standard fields
108
+ if (origFields.description !== undefined && origFields.description !== null) {
109
+ fields.description = origFields.description;
110
+ }
111
+
112
+ if (origFields.priority) {
113
+ if (origFields.priority.id) {
114
+ fields.priority = { id: String(origFields.priority.id) };
115
+ } else if (origFields.priority.name) {
116
+ fields.priority = { name: String(origFields.priority.name) };
117
+ }
118
+ }
119
+
120
+ if (Array.isArray(origFields.components) && origFields.components.length > 0) {
121
+ const components = origFields.components
122
+ .map((c) => (c.id ? { id: String(c.id) } : c.name ? { name: String(c.name) } : null))
123
+ .filter(Boolean);
124
+ if (components.length > 0) fields.components = components;
125
+ }
126
+
127
+ if (Array.isArray(origFields.labels) && origFields.labels.length > 0) {
128
+ const labels = origFields.labels.map((l) => String(l)).filter(Boolean);
129
+ if (labels.length > 0) fields.labels = labels;
130
+ }
131
+
132
+ if (Array.isArray(origFields.fixVersions) && origFields.fixVersions.length > 0) {
133
+ const fixVersions = origFields.fixVersions
134
+ .map((v) => (v.id ? { id: String(v.id) } : v.name ? { name: String(v.name) } : null))
135
+ .filter(Boolean);
136
+ if (fixVersions.length > 0) fields.fixVersions = fixVersions;
137
+ }
138
+
139
+ if (Array.isArray(origFields.versions) && origFields.versions.length > 0) {
140
+ const versions = origFields.versions
141
+ .map((v) => (v.id ? { id: String(v.id) } : v.name ? { name: String(v.name) } : null))
142
+ .filter(Boolean);
143
+ if (versions.length > 0) fields.versions = versions;
144
+ }
145
+
146
+ if (typeof origFields.environment === 'string' && origFields.environment) {
147
+ fields.environment = origFields.environment;
148
+ }
149
+
150
+ if (typeof origFields.duedate === 'string' && origFields.duedate) {
151
+ fields.duedate = origFields.duedate;
152
+ }
153
+
154
+ if (origFields.security) {
155
+ if (origFields.security.id) fields.security = { id: String(origFields.security.id) };
156
+ else if (origFields.security.name) fields.security = { name: String(origFields.security.name) };
157
+ }
158
+
159
+ if (origFields.parent) {
160
+ if (origFields.parent.id) fields.parent = { id: String(origFields.parent.id) };
161
+ else if (origFields.parent.key) fields.parent = { key: String(origFields.parent.key) };
162
+ }
163
+
164
+ if (origFields.assignee) {
165
+ if (origFields.assignee.accountId) fields.assignee = { accountId: String(origFields.assignee.accountId) };
166
+ else if (origFields.assignee.name) fields.assignee = { name: String(origFields.assignee.name) };
167
+ }
168
+
169
+ // Custom fields starting with customfield_
170
+ for (const [key, val] of Object.entries(origFields)) {
171
+ if (key.startsWith('customfield_') && val !== null && val !== undefined) {
172
+ const transformed = transformFieldValue(val);
173
+ if (transformed !== undefined) {
174
+ fields[key] = transformed;
175
+ }
176
+ }
177
+ }
178
+
179
+ return fields;
180
+ }
181
+
57
182
  // List all filters
58
183
  router.get('/', (_req, res) => {
59
184
  res.json(listJiraFilters());
@@ -271,12 +396,11 @@ router.post('/issues/clone', async (req, res) => {
271
396
  recordApiError({ source: 'Jira API (Clone Issue)', method: 'GET', url: myselfUrl, message: error.message || 'Failed to fetch current user.' });
272
397
  }
273
398
 
274
- // Fetch original issue details to preserve issue type, project, description, priority
399
+ // Fetch original issue details to preserve issue type, project, description, priority, components, labels, fixVersions, versions, environment, duedate, security, parent, custom fields
400
+ let origFields = null;
275
401
  let origIssueType = null;
276
402
  let origProjectKey = null;
277
403
  let origSummary = null;
278
- let origDescription = null;
279
- let origPriority = null;
280
404
 
281
405
  if (issueKey) {
282
406
  const issueUrl = `https://${jiraHost}/rest/api/2/issue/${encodeURIComponent(issueKey)}`;
@@ -286,29 +410,19 @@ router.post('/issues/clone', async (req, res) => {
286
410
  recordApiError({ source: 'Jira API (Clone Issue)', method: 'GET', url: issueUrl, status: origRes.status, message: `Jira issue API returned ${origRes.status}: ${origRes.statusText || 'Failed to fetch original issue.'}` });
287
411
  }
288
412
  if (origRes.status === 200 && origRes.data?.fields) {
289
- const fields = origRes.data.fields;
290
- if (fields.issuetype) {
291
- if (fields.issuetype.id) {
292
- origIssueType = { id: fields.issuetype.id };
293
- } else if (fields.issuetype.name) {
294
- origIssueType = { name: fields.issuetype.name };
413
+ origFields = origRes.data.fields;
414
+ if (origFields.issuetype) {
415
+ if (origFields.issuetype.id) {
416
+ origIssueType = { id: String(origFields.issuetype.id) };
417
+ } else if (origFields.issuetype.name) {
418
+ origIssueType = { name: String(origFields.issuetype.name) };
295
419
  }
296
420
  }
297
- if (fields.project?.key) {
298
- origProjectKey = fields.project.key;
299
- }
300
- if (fields.summary) {
301
- origSummary = String(fields.summary).trim();
421
+ if (origFields.project?.key) {
422
+ origProjectKey = origFields.project.key;
302
423
  }
303
- if (fields.description) {
304
- origDescription = fields.description;
305
- }
306
- if (fields.priority) {
307
- if (fields.priority.id) {
308
- origPriority = { id: fields.priority.id };
309
- } else if (fields.priority.name) {
310
- origPriority = { name: fields.priority.name };
311
- }
424
+ if (origFields.summary) {
425
+ origSummary = String(origFields.summary).trim();
312
426
  }
313
427
  }
314
428
  } catch (error) {
@@ -321,26 +435,60 @@ router.post('/issues/clone', async (req, res) => {
321
435
  const projectKey = origProjectKey || (issueKey ? issueKey.split('-')[0] : '');
322
436
  const requestedSummary = summary.trim();
323
437
  const cloneSummary = origSummary || requestedSummary;
438
+
439
+ // Extract all cloneable standard & custom fields from original issue
440
+ const cloneableFields = extractCloneableFields(origFields);
441
+
442
+ // Override assignee with current authenticated user if resolved
443
+ if (assigneeField) {
444
+ cloneableFields.assignee = assigneeField;
445
+ }
446
+
447
+ // Core fields required for issue creation
448
+ const coreFields = {
449
+ summary: cloneSummary,
450
+ ...(projectKey ? { project: { key: projectKey } } : {}),
451
+ issuetype: targetIssueType
452
+ };
453
+
454
+ let activeFields = { ...cloneableFields, ...coreFields };
455
+ let pendingPutFields = {};
456
+
324
457
  const url = `https://${jiraHost}/rest/api/2/issue`;
325
- const payload = {
326
- fields: {
327
- summary: cloneSummary,
328
- ...(projectKey ? { project: { key: projectKey } } : {}),
329
- issuetype: targetIssueType,
330
- ...(assigneeField ? { assignee: assigneeField } : {}),
331
- ...(origDescription ? { description: origDescription } : {}),
332
- ...(origPriority ? { priority: origPriority } : {})
458
+ let response = await httpClient.post(url, { fields: activeFields }, { headers });
459
+
460
+ // Handle field rejection on POST creation (e.g. fields not configured on Create Screen)
461
+ let maxRetries = 3;
462
+ while (response.status !== 201 && response.status !== 200 && maxRetries > 0) {
463
+ maxRetries--;
464
+ const errFields = response.data?.errors ? Object.keys(response.data.errors) : [];
465
+ if (errFields.length > 0) {
466
+ let strippedAny = false;
467
+ for (const errField of errFields) {
468
+ if (activeFields[errField] !== undefined && !['summary', 'project', 'issuetype'].includes(errField)) {
469
+ pendingPutFields[errField] = activeFields[errField];
470
+ delete activeFields[errField];
471
+ strippedAny = true;
472
+ }
473
+ }
474
+ if (strippedAny) {
475
+ response = await httpClient.post(url, { fields: activeFields }, { headers });
476
+ continue;
477
+ }
333
478
  }
334
- };
479
+ break;
480
+ }
335
481
 
336
- let response = await httpClient.post(url, payload, { headers });
337
- // Fallback if optional fields like description or priority caused issue creation rejection
338
- if (response.status !== 201 && response.status !== 200 && (origPriority || origDescription)) {
482
+ // Fallback attempt with minimal core fields if POST creation still failed
483
+ if (response.status !== 201 && response.status !== 200) {
484
+ for (const [k, v] of Object.entries(activeFields)) {
485
+ if (!['summary', 'project', 'issuetype'].includes(k)) {
486
+ pendingPutFields[k] = v;
487
+ }
488
+ }
339
489
  const fallbackPayload = {
340
490
  fields: {
341
- summary: cloneSummary,
342
- ...(projectKey ? { project: { key: projectKey } } : {}),
343
- issuetype: targetIssueType,
491
+ ...coreFields,
344
492
  ...(assigneeField ? { assignee: assigneeField } : {})
345
493
  }
346
494
  };
@@ -358,7 +506,28 @@ router.post('/issues/clone', async (req, res) => {
358
506
  const created = response.data || {};
359
507
  const createdKey = created.key || issueKey;
360
508
 
361
- // Fallback: Assign explicitly if not assigned during creation
509
+ // Apply pending fields via PUT update if any fields could not be set on creation screen
510
+ if (createdKey && Object.keys(pendingPutFields).length > 0) {
511
+ const updateUrl = `https://${jiraHost}/rest/api/2/issue/${encodeURIComponent(createdKey)}`;
512
+ try {
513
+ let putRes = await httpClient.put(updateUrl, { fields: pendingPutFields }, { headers });
514
+ if (putRes.status < 200 || putRes.status >= 300) {
515
+ const putErrFields = putRes.data?.errors ? Object.keys(putRes.data.errors) : [];
516
+ if (putErrFields.length > 0) {
517
+ for (const ef of putErrFields) {
518
+ delete pendingPutFields[ef];
519
+ }
520
+ if (Object.keys(pendingPutFields).length > 0) {
521
+ await httpClient.put(updateUrl, { fields: pendingPutFields }, { headers });
522
+ }
523
+ }
524
+ }
525
+ } catch (err) {
526
+ recordApiError({ source: 'Jira API (Clone Issue PUT fields)', method: 'PUT', url: `https://${jiraHost}/rest/api/2/issue/${createdKey}`, message: err.message || 'Failed to PUT cloned fields.' });
527
+ }
528
+ }
529
+
530
+ // Fallback: Assign explicitly if not assigned during creation/PUT
362
531
  if (createdKey && assigneeField) {
363
532
  const assignUrl = `https://${jiraHost}/rest/api/2/issue/${createdKey}/assignee`;
364
533
  try {
@@ -83,29 +83,55 @@ function buildReleaseList(metadata) {
83
83
  }
84
84
 
85
85
  async function readNpmPackageMetadata() {
86
- // Do not depend on npm being present in the server process PATH. On Windows,
87
- // a UI launched from a shortcut often has a reduced PATH and `npm.cmd view`
88
- // fails even though npm works in an interactive terminal.
89
- const registry = String(process.env.npm_config_registry || 'https://registry.npmjs.org/')
90
- .trim()
91
- .replace(/\/+$/, '');
92
- const response = await fetch(`${registry}/${encodeURIComponent(PACKAGE_NAME)}`, {
93
- headers: { Accept: 'application/json' },
94
- signal: AbortSignal.timeout(8000)
95
- });
96
- if (!response.ok) {
97
- throw new Error(`The configured npm registry returned HTTP ${response.status}.`);
98
- }
99
- const packageMetadata = await response.json();
100
- const metadata = {
101
- version: packageMetadata?.['dist-tags']?.latest,
102
- time: packageMetadata?.time,
103
- devbuddyChangelog: packageMetadata?.devbuddyChangelog
104
- };
105
- if (!metadata.version) {
106
- throw new Error('The configured npm registry did not return package version metadata.');
86
+ // npm knows about the user's ~/.npmrc, including private registries,
87
+ // proxies, authentication and custom CA settings. This matters in the
88
+ // internal-network desktop build, where those settings are not necessarily
89
+ // exposed as npm_config_* environment variables.
90
+ const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
91
+ try {
92
+ const { stdout } = await execFileAsync(
93
+ npmCommand,
94
+ ['view', `${PACKAGE_NAME}@latest`, 'version', 'time', 'devbuddyChangelog', '--json'],
95
+ {
96
+ timeout: 8000,
97
+ maxBuffer: 1024 * 1024,
98
+ shell: process.platform === 'win32',
99
+ windowsHide: true
100
+ }
101
+ );
102
+ const metadata = JSON.parse(stdout);
103
+ if (!metadata || typeof metadata !== 'object' || !metadata.version) {
104
+ throw new Error('The configured npm registry did not return package version metadata.');
105
+ }
106
+ return metadata;
107
+ } catch (npmError) {
108
+ // Keep a fallback for desktop launchers whose reduced PATH cannot find
109
+ // npm. It is intentionally secondary so it cannot bypass ~/.npmrc.
110
+ const registry = String(process.env.npm_config_registry || 'https://registry.npmjs.org/')
111
+ .trim()
112
+ .replace(/\/+$/, '');
113
+ try {
114
+ const response = await fetch(`${registry}/${encodeURIComponent(PACKAGE_NAME)}`, {
115
+ headers: { Accept: 'application/json' },
116
+ signal: AbortSignal.timeout(8000)
117
+ });
118
+ if (!response.ok) {
119
+ throw new Error(`The configured npm registry returned HTTP ${response.status}.`);
120
+ }
121
+ const packageMetadata = await response.json();
122
+ const metadata = {
123
+ version: packageMetadata?.['dist-tags']?.latest,
124
+ time: packageMetadata?.time,
125
+ devbuddyChangelog: packageMetadata?.devbuddyChangelog
126
+ };
127
+ if (!metadata.version) {
128
+ throw new Error('The configured npm registry did not return package version metadata.');
129
+ }
130
+ return metadata;
131
+ } catch {
132
+ throw npmError;
133
+ }
107
134
  }
108
- return metadata;
109
135
  }
110
136
 
111
137
  async function checkForUpdates() {
@@ -13,6 +13,7 @@ const execFileAsync = promisify(execFile);
13
13
  const previewLimit = 2000;
14
14
  let lastValue = '';
15
15
  let lastImageHash = '';
16
+ let suppressImageCaptureUntil = 0;
16
17
  let mozjpegWasmModule = null;
17
18
 
18
19
  async function compressMozjpeg(rgbaBuf, width, height, quality = 75) {
@@ -304,7 +305,7 @@ export async function captureClipboard() {
304
305
  } catch {}
305
306
 
306
307
  // 2. Image capture (Mac only & clipboardImageEnabled !== false)
307
- if (process.platform === 'darwin' && settings.clipboardImageEnabled !== false) {
308
+ if (process.platform === 'darwin' && settings.clipboardImageEnabled !== false && Date.now() >= suppressImageCaptureUntil) {
308
309
  try {
309
310
  const imageData = await getMacClipboardImageData();
310
311
  if (imageData && imageData.rgbaBuf.length > 0) {
@@ -389,24 +390,13 @@ export async function markClipboardCopied(date, id) {
389
390
  if (!item) return null;
390
391
 
391
392
  if (item.imageFile) {
392
- try {
393
- const imageData = await getMacClipboardImageData();
394
- if (imageData && imageData.rgbaBuf.length > 0) {
395
- const currentHash = createHash('md5').update(imageData.rgbaBuf).digest('hex');
396
- lastImageHash = currentHash;
397
-
398
- const existingHashes = Array.isArray(item.imageHashes)
399
- ? item.imageHashes
400
- : [item.imageHash, item.fileHash].filter(Boolean);
401
-
402
- if (!existingHashes.includes(currentHash)) {
403
- const updatedItem = { ...item, imageHashes: [...existingHashes, currentHash] };
404
- const nextItems = items.map((entry) => (entry.id === id ? updatedItem : entry));
405
- writeJson(dayFile(targetDate), nextItems);
406
- return updatedItem;
407
- }
408
- }
409
- } catch {}
393
+ // Set the monitor's last hash before the browser writes the image. This
394
+ // prevents copying an existing image from being captured as a new item.
395
+ suppressImageCaptureUntil = Date.now() + 5000;
396
+ const existingHashes = Array.isArray(item.imageHashes)
397
+ ? item.imageHashes
398
+ : [item.imageHash, item.fileHash].filter(Boolean);
399
+ lastImageHash = existingHashes[0] || '';
410
400
  } else {
411
401
  const text = clipboardOriginal(targetDate, id) || item.text || item.preview || '';
412
402
  if (text) {
@@ -416,6 +406,37 @@ export async function markClipboardCopied(date, id) {
416
406
  return item;
417
407
  }
418
408
 
409
+ export async function copyClipboardImage(date, id) {
410
+ if (process.platform !== 'darwin') throw new Error('Native image clipboard is only supported on macOS.');
411
+
412
+ let item = dayItems(date).find((entry) => entry.id === id);
413
+ if (!item) {
414
+ for (const d of clipboardDates()) {
415
+ item = dayItems(d).find((entry) => entry.id === id);
416
+ if (item) break;
417
+ }
418
+ }
419
+ if (!item?.imageFile) throw new Error('Clipboard image entry not found.');
420
+
421
+ await markClipboardCopied(date, id);
422
+ const imagePath = clipboardImagePath(item.imageFile);
423
+ const swiftCode = `
424
+ import Cocoa
425
+ let path = CommandLine.arguments[1]
426
+ if let image = NSImage(contentsOfFile: path),
427
+ let tiff = image.tiffRepresentation,
428
+ let rep = NSBitmapImageRep(data: tiff),
429
+ let png = rep.representation(using: .png, properties: [:]) {
430
+ let pasteboard = NSPasteboard.general
431
+ pasteboard.clearContents()
432
+ if !pasteboard.setData(png, forType: .png) { exit(1) }
433
+ } else {
434
+ exit(1)
435
+ }
436
+ `;
437
+ await execFileAsync('swift', ['-e', swiftCode, imagePath], { timeout: 5000 });
438
+ }
439
+
419
440
  export function saveClipboardImage(buffer, ext = 'png', imageHash = '') {
420
441
  const date = today();
421
442
  const id = randomUUID();