buddy-workbench 0.1.8 → 0.1.10

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.
@@ -0,0 +1,177 @@
1
+ import { Router } from 'express';
2
+ import { listTodos, saveTodos } from '../repositories/todos.js';
3
+
4
+ const router = Router();
5
+
6
+ function validTodo(body) {
7
+ const title = String(body?.title || '').trim();
8
+ if (!title) return null;
9
+
10
+ const priority = ['high', 'medium', 'low'].includes(body?.priority) ? body.priority : 'medium';
11
+ const category = String(body?.category || 'work').trim() || 'work';
12
+ const description = String(body?.description || '').trim();
13
+ const dueDate = body?.dueDate ? String(body.dueDate).trim() : null;
14
+ const completed = Boolean(body?.completed);
15
+ const archived = Boolean(body?.archived);
16
+
17
+ return {
18
+ title,
19
+ description,
20
+ priority,
21
+ category,
22
+ dueDate,
23
+ completed,
24
+ archived
25
+ };
26
+ }
27
+
28
+ router.get('/', (_req, res) => {
29
+ res.json(listTodos());
30
+ });
31
+
32
+ router.post('/', (req, res) => {
33
+ const data = validTodo(req.body);
34
+ if (!data) {
35
+ return res.status(400).json({ error: 'Title is required.' });
36
+ }
37
+
38
+ const now = new Date().toISOString();
39
+ const newTodo = {
40
+ id: crypto.randomUUID(),
41
+ ...data,
42
+ completedAt: data.completed ? now : null,
43
+ archivedAt: data.archived ? now : null,
44
+ createdAt: now,
45
+ updatedAt: now
46
+ };
47
+
48
+ const todos = listTodos();
49
+ todos.unshift(newTodo);
50
+ saveTodos(todos);
51
+
52
+ res.status(201).json(newTodo);
53
+ });
54
+
55
+ router.put('/:id', (req, res) => {
56
+ const { id } = req.params;
57
+ const todos = listTodos();
58
+ const index = todos.findIndex((item) => item.id === id);
59
+
60
+ if (index < 0) {
61
+ return res.status(404).json({ error: 'Todo item not found.' });
62
+ }
63
+
64
+ const data = validTodo(req.body);
65
+ if (!data) {
66
+ return res.status(400).json({ error: 'Title is required.' });
67
+ }
68
+
69
+ const existing = todos[index];
70
+ const now = new Date().toISOString();
71
+ const completedAt = data.completed
72
+ ? (existing.completed ? existing.completedAt : now)
73
+ : null;
74
+ const archivedAt = data.archived
75
+ ? (existing.archived ? existing.archivedAt : now)
76
+ : null;
77
+
78
+ const updated = {
79
+ ...existing,
80
+ ...data,
81
+ completedAt,
82
+ archivedAt,
83
+ updatedAt: now
84
+ };
85
+
86
+ todos[index] = updated;
87
+ saveTodos(todos);
88
+
89
+ res.json(updated);
90
+ });
91
+
92
+ router.post('/:id/archive', (req, res) => {
93
+ const { id } = req.params;
94
+ const todos = listTodos();
95
+ const index = todos.findIndex((item) => item.id === id);
96
+
97
+ if (index < 0) {
98
+ return res.status(404).json({ error: 'Todo item not found.' });
99
+ }
100
+
101
+ const now = new Date().toISOString();
102
+ const updated = {
103
+ ...todos[index],
104
+ archived: true,
105
+ archivedAt: now,
106
+ updatedAt: now
107
+ };
108
+
109
+ todos[index] = updated;
110
+ saveTodos(todos);
111
+ res.json(updated);
112
+ });
113
+
114
+ router.post('/:id/unarchive', (req, res) => {
115
+ const { id } = req.params;
116
+ const todos = listTodos();
117
+ const index = todos.findIndex((item) => item.id === id);
118
+
119
+ if (index < 0) {
120
+ return res.status(404).json({ error: 'Todo item not found.' });
121
+ }
122
+
123
+ const now = new Date().toISOString();
124
+ const updated = {
125
+ ...todos[index],
126
+ archived: false,
127
+ archivedAt: null,
128
+ updatedAt: now
129
+ };
130
+
131
+ todos[index] = updated;
132
+ saveTodos(todos);
133
+ res.json(updated);
134
+ });
135
+
136
+ router.delete('/:id', (req, res) => {
137
+ const { id } = req.params;
138
+ const todos = listTodos();
139
+ const next = todos.filter((item) => item.id !== id);
140
+
141
+ if (next.length === todos.length) {
142
+ return res.status(404).json({ error: 'Todo item not found.' });
143
+ }
144
+
145
+ saveTodos(next);
146
+ res.status(204).end();
147
+ });
148
+
149
+ router.post('/batch', (req, res) => {
150
+ const { action, ids } = req.body || {};
151
+ let todos = listTodos();
152
+ const now = new Date().toISOString();
153
+ const idSet = new Set(Array.isArray(ids) ? ids : []);
154
+
155
+ if (action === 'markCompleted') {
156
+ todos = todos.map((item) => (idSet.has(item.id) ? { ...item, completed: true, completedAt: item.completedAt || now, updatedAt: now } : item));
157
+ } else if (action === 'markPending') {
158
+ todos = todos.map((item) => (idSet.has(item.id) ? { ...item, completed: false, completedAt: null, updatedAt: now } : item));
159
+ } else if (action === 'archive') {
160
+ todos = todos.map((item) => (idSet.has(item.id) ? { ...item, archived: true, archivedAt: now, updatedAt: now } : item));
161
+ } else if (action === 'unarchive') {
162
+ todos = todos.map((item) => (idSet.has(item.id) ? { ...item, archived: false, archivedAt: null, updatedAt: now } : item));
163
+ } else if (action === 'delete') {
164
+ todos = todos.filter((item) => !idSet.has(item.id));
165
+ } else if (action === 'clearCompleted') {
166
+ todos = todos.filter((item) => !item.completed || item.archived);
167
+ } else if (action === 'clearArchived') {
168
+ todos = todos.filter((item) => !item.archived);
169
+ } else {
170
+ return res.status(400).json({ error: 'Invalid batch action.' });
171
+ }
172
+
173
+ saveTodos(todos);
174
+ res.json(todos);
175
+ });
176
+
177
+ export default router;
@@ -0,0 +1,28 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readSettings } from '../repositories/settings.js';
3
+
4
+ export function openInSystemBrowser(url) {
5
+ if (!url || typeof url !== 'string') return;
6
+ const settings = readSettings();
7
+ const browser = settings.defaultBrowser || 'chrome';
8
+
9
+ if (process.platform === 'darwin') {
10
+ const macAppMap = {
11
+ chrome: 'Google Chrome',
12
+ edge: 'Microsoft Edge',
13
+ safari: 'Safari'
14
+ };
15
+ const appName = macAppMap[browser] || 'Google Chrome';
16
+ execFile('open', ['-a', appName, url]);
17
+ } else if (process.platform === 'win32') {
18
+ const winCmdMap = {
19
+ chrome: 'chrome',
20
+ edge: 'msedge',
21
+ safari: 'msedge'
22
+ };
23
+ const cmd = winCmdMap[browser] || 'chrome';
24
+ execFile('cmd', ['/c', 'start', '', cmd, url]);
25
+ } else {
26
+ execFile('xdg-open', [url]);
27
+ }
28
+ }
@@ -19,8 +19,74 @@ export function clipboardDates() {
19
19
  return readdirSync(paths.clipboardDir, { withFileTypes: true }).filter((year) => year.isDirectory() && /^\d{4}$/.test(year.name)).flatMap((year) => readdirSync(join(paths.clipboardDir, year.name), { withFileTypes: true }).filter((month) => month.isDirectory() && /^\d{2}$/.test(month.name)).flatMap((month) => readdirSync(join(paths.clipboardDir, year.name, month.name), { withFileTypes: true }).filter((day) => day.isFile() && /^\d{2}\.json$/.test(day.name)).map((day) => `${year.name}-${month.name}-${day.name.slice(0, 2)}`))).sort().reverse();
20
20
  }
21
21
 
22
+ function getEditorUrl(editor = 'vscode', path = '') {
23
+ if (editor === 'idea') return `idea://open?file=${encodeURIComponent(path)}`;
24
+ if (editor === 'devin') return `devin://file${encodeURI(path)}`;
25
+ return `vscode://file${encodeURI(path)}`;
26
+ }
27
+
22
28
  function dayItems(date) { return readJson(dayFile(date), []); }
23
- export function clipboardItems(date = today()) { return dayItems(date).map((item) => item.contentFile ? { ...item, editorUrl: `vscode://file${encodeURI(join(paths.clipboardDir, 'content', item.contentFile))}` } : item); }
29
+ export function clipboardItems(date = today()) {
30
+ const settings = readSettings();
31
+ const editor = settings.defaultEditor || 'vscode';
32
+ const editorName = editor === 'idea' ? 'IntelliJ IDEA' : editor === 'devin' ? 'Devin' : 'VS Code';
33
+ return dayItems(date).map((item) => item.contentFile ? { ...item, editorUrl: getEditorUrl(editor, join(paths.clipboardDir, 'content', item.contentFile)), editorName } : item);
34
+ }
35
+ export function allTaggedClipboardItems() {
36
+ const settings = readSettings();
37
+ const editor = settings.defaultEditor || 'vscode';
38
+ const editorName = editor === 'idea' ? 'IntelliJ IDEA' : editor === 'devin' ? 'Devin' : 'VS Code';
39
+ const dates = clipboardDates();
40
+ const result = [];
41
+
42
+ for (const date of dates) {
43
+ const items = dayItems(date);
44
+ for (const item of items) {
45
+ if (Array.isArray(item.tags) && item.tags.length > 0) {
46
+ result.push(
47
+ item.contentFile
48
+ ? {
49
+ ...item,
50
+ date,
51
+ editorUrl: getEditorUrl(editor, join(paths.clipboardDir, 'content', item.contentFile)),
52
+ editorName
53
+ }
54
+ : { ...item, date }
55
+ );
56
+ }
57
+ }
58
+ }
59
+ return result;
60
+ }
61
+
62
+ export function updateClipboardItemTags(date, id, tags) {
63
+ let targetDate = date;
64
+ let items = dayItems(targetDate);
65
+ let item = items.find((entry) => entry.id === id);
66
+
67
+ if (!item) {
68
+ const dates = clipboardDates();
69
+ for (const d of dates) {
70
+ const dayList = dayItems(d);
71
+ const found = dayList.find((entry) => entry.id === id);
72
+ if (found) {
73
+ targetDate = d;
74
+ items = dayList;
75
+ item = found;
76
+ break;
77
+ }
78
+ }
79
+ }
80
+
81
+ if (!item) return null;
82
+
83
+ const validTags = Array.isArray(tags) ? tags.map((t) => String(t).trim()).filter(Boolean) : [];
84
+ const updatedItem = { ...item, tags: validTags };
85
+ const nextItems = items.map((entry) => (entry.id === id ? updatedItem : entry));
86
+ writeJson(dayFile(targetDate), nextItems);
87
+ return updatedItem;
88
+ }
89
+
24
90
  export function clipboardOriginal(date, id) { const item = dayItems(date).find((entry) => entry.id === id); if (!item?.contentFile) return item?.text || null; try { return readFileSync(join(paths.clipboardDir, 'content', item.contentFile), 'utf8'); } catch { return null; } }
25
91
  export function deleteClipboardItem(date, id) {
26
92
  const items = dayItems(date); const item = items.find((entry) => entry.id === id);
@@ -82,18 +148,18 @@ export async function captureClipboard() {
82
148
  if (alreadyExists) return;
83
149
 
84
150
  const id = crypto.randomUUID(); const isLong = text.length > previewLimit;
85
- if (isLong) { mkdirSync(join(paths.clipboardDir, 'content'), { recursive: true }); writeFileSync(join(paths.clipboardDir, 'content', `${id}.txt`), text, 'utf8'); }
86
- const item = isLong ? { id, preview: `${text.slice(0, previewLimit)}…`, contentFile: `${id}.txt`, createdAt: new Date().toISOString() } : { id, text, createdAt: new Date().toISOString() };
151
+ if (isLong) { mkdirSync(join(paths.clipboardDir, 'content'), { recursive: true }); writeFileSync(join(paths.clipboardDir, 'content', id), text, 'utf8'); }
152
+ const item = isLong ? { id, preview: `${text.slice(0, previewLimit)}…`, contentFile: id, createdAt: new Date().toISOString() } : { id, text, createdAt: new Date().toISOString() };
87
153
  writeJson(dayFile(date), [item, ...items].slice(0, 200));
88
154
  } catch {}
89
155
  }
90
156
 
91
157
  export function startClipboardCapture() { captureClipboard(); const timer = setInterval(captureClipboard, 2000); timer.unref(); }
92
158
 
93
- export function saveClipboardImage(buffer) {
159
+ export function saveClipboardImage(buffer, ext = 'png') {
94
160
  const date = today();
95
161
  const id = crypto.randomUUID();
96
- const filename = `${id}.png`;
162
+ const filename = `${id}.${ext}`;
97
163
 
98
164
  mkdirSync(join(paths.clipboardDir, 'content'), { recursive: true });
99
165
  writeFileSync(join(paths.clipboardDir, 'content', filename), buffer);
@@ -14,13 +14,69 @@ const savedPortHistory = new Map(listPortHistory().map((record) => [record.port,
14
14
  const supervisorPath = fileURLToPath(new URL('./script-supervisor.js', import.meta.url));
15
15
  const execFileAsync = promisify(execFile);
16
16
  const keyFor = (launcherId, scriptId) => `${launcherId}:${scriptId}`;
17
+ function countErrorBlocks(text, isStderr = false) {
18
+ if (!text) return 0;
19
+ const lines = text.split(/\r?\n/);
20
+ let count = 0;
21
+ let inErrorBlock = false;
22
+
23
+ for (const line of lines) {
24
+ const trimmed = line.trim();
25
+ if (!trimmed) {
26
+ inErrorBlock = false;
27
+ continue;
28
+ }
29
+
30
+ const isNewHeader =
31
+ /^(?:\[?(?:error|fatal|fail|failed)\]?|error:|uncaught|fatal:)/i.test(trimmed) ||
32
+ /^(?:TypeError|ReferenceError|SyntaxError|RangeError|EvalError|URIError|Error|UnhandledPromiseRejectionWarning|UnhandledPromiseRejection):/i.test(trimmed) ||
33
+ /^npm ERR! (?:code|syscall|path|errno)/i.test(trimmed) ||
34
+ /^\[vite\] Internal server error/i.test(trimmed) ||
35
+ /\bTS\d{4,5}\b/i.test(trimmed) ||
36
+ /\berror TS\d+/i.test(trimmed) ||
37
+ /^(?:\[TypeScript\]|TS ERROR)/i.test(trimmed);
38
+
39
+ if (isNewHeader) {
40
+ inErrorBlock = false;
41
+ }
42
+
43
+ const isErrorLine =
44
+ isStderr ||
45
+ isNewHeader ||
46
+ /\b(error|failed|fatal|exception)\b/i.test(trimmed) ||
47
+ /^npm ERR!/i.test(trimmed) ||
48
+ /\bTS\d{4,5}\b/i.test(trimmed) ||
49
+ /^\s*at\s+[\w\d_$.<>]+\s+\(/i.test(line);
50
+
51
+ if (isErrorLine) {
52
+ if (!inErrorBlock) {
53
+ count++;
54
+ inErrorBlock = true;
55
+ }
56
+ } else {
57
+ inErrorBlock = false;
58
+ }
59
+ }
60
+
61
+ return count;
62
+ }
63
+
17
64
  const appendLog = (key, type, chunk) => {
18
- const current = logs.get(key) || { output: '', error: '' };
19
- current[type] = `${current[type]}${chunk}`.split(/\r?\n/).slice(-1000).join('\n');
65
+ const current = logs.get(key) || { output: '', error: '', errorCount: 0 };
66
+ const str = String(chunk || '');
67
+ current[type] = `${current[type]}${str}`.split(/\r?\n/).slice(-1000).join('\n');
68
+ current.errorCount = countErrorBlocks(current.error, true) + countErrorBlocks(current.output, false);
20
69
  logs.set(key, current);
21
70
  };
22
71
 
23
72
  export function runningScripts() { return [...running.keys()]; }
73
+ export function runningErrorCounts() {
74
+ const counts = {};
75
+ for (const [key, data] of logs.entries()) {
76
+ if (running.has(key) && data?.errorCount) counts[key] = data.errorCount;
77
+ }
78
+ return counts;
79
+ }
24
80
  export function scriptLogs(launcherId, scriptId) { return logs.get(keyFor(launcherId, scriptId))?.output || ''; }
25
81
  export function scriptErrorLogs(launcherId, scriptId) { return logs.get(keyFor(launcherId, scriptId))?.error || ''; }
26
82
 
@@ -149,7 +205,7 @@ export function runScript(launcher, script) {
149
205
  const key = keyFor(launcher.id, script.id);
150
206
  if (running.has(key)) throw new Error('This script is already running.');
151
207
  if (!existsSync(launcher.folder)) throw new Error('The configured project folder does not exist.');
152
- logs.set(key, { output: `$ ${script.command}\n`, error: '' });
208
+ logs.set(key, { output: `$ ${script.command}\n`, error: '', errorCount: 0 });
153
209
  const child = spawn(process.execPath, [supervisorPath], { cwd: launcher.folder, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...(launcher.executor === 'pnpm' || /^\s*pnpm\b/.test(script.command) ? { CI: 'true' } : {}), BUDDY_PARENT_PID: String(process.pid), BUDDY_SCRIPT_COMMAND: script.command } });
154
210
  child.stdout.on('data', (chunk) => appendLog(key, 'output', chunk.toString()));
155
211
  child.stderr.on('data', (chunk) => appendLog(key, 'error', chunk.toString()));
@@ -158,7 +214,13 @@ export function runScript(launcher, script) {
158
214
  launchHistory.push({ key, groupPid: child.pid, launcher: launcher.alias, script: script.name, startedAt: new Date().toISOString(), ports: new Map() });
159
215
  if (launchHistory.length > 200) launchHistory.splice(0, launchHistory.length - 200);
160
216
  setTimeout(() => { void observeLauncherPorts().catch(() => {}); }, 1000).unref();
161
- child.on('exit', (code) => { appendLog(key, code === 0 ? 'output' : 'error', `\nProcess exited with code ${code}.\n`); running.delete(key); retireGroup(child.pid); });
217
+ child.on('exit', (code) => {
218
+ appendLog(key, code === 0 ? 'output' : 'error', `\nProcess exited with code ${code}.\n`);
219
+ const item = logs.get(key);
220
+ if (item) item.errorCount = 0;
221
+ running.delete(key);
222
+ retireGroup(child.pid);
223
+ });
162
224
  }
163
225
 
164
226
  export function stopScript(launcherId, scriptId) {
@@ -166,6 +228,8 @@ export function stopScript(launcherId, scriptId) {
166
228
  const child = running.get(key);
167
229
  if (!child) throw new Error('This script is not running.');
168
230
  appendLog(key, 'output', '\nStop requested.\n');
231
+ const item = logs.get(key);
232
+ if (item) item.errorCount = 0;
169
233
  stoppingGroups.add(child.pid);
170
234
  terminateGroup(child.pid, 'SIGTERM');
171
235
  setTimeout(() => { terminateGroup(child.pid, 'SIGKILL'); stoppingGroups.delete(child.pid); processGroups.delete(child.pid); }, 1500).unref();
package/server.js CHANGED
@@ -13,6 +13,9 @@ import groupTaskRoutes from './server/routes/group-tasks.js';
13
13
  import portDiagnosticsRoutes from './server/routes/port-diagnostics.js';
14
14
  import settingsRoutes from './server/routes/settings.js';
15
15
  import prReviewRoutes from './server/routes/pr-review.js';
16
+ import jiraFiltersRoutes from './server/routes/jira-filters.js';
17
+ import todoRoutes from './server/routes/todos.js';
18
+ import staticPagesRoutes from './server/routes/static-pages.js';
16
19
  import { startClipboardCapture } from './server/services/clipboard-history.js';
17
20
 
18
21
  const port = Number(process.env.PORT || 3100);
@@ -27,6 +30,9 @@ app.use('/api/group-tasks', groupTaskRoutes);
27
30
  app.use('/api/port-diagnostics', portDiagnosticsRoutes);
28
31
  app.use('/api/settings', settingsRoutes);
29
32
  app.use('/api/pr-review', prReviewRoutes);
33
+ app.use('/api/jira-filters', jiraFiltersRoutes);
34
+ app.use('/api/todos', todoRoutes);
35
+ app.use('/api/static-pages', staticPagesRoutes);
30
36
 
31
37
  await freePort(port);
32
38
  const server = app.listen(port, () => { console.log(`Buddy Workbench: http://localhost:${port} (pid ${process.pid})`); shutdownTrace(`server started (pid=${process.pid})`); });