buddy-workbench 0.1.109 → 0.1.111

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.109",
3
+ "version": "0.1.111",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
package/server/config.js CHANGED
@@ -29,6 +29,8 @@ export const paths = {
29
29
  jiraTemplates: join(userDataDir, 'jira-templates.json'),
30
30
  jiraRecentlyCreated: join(userDataDir, 'jira-recently-created.json'),
31
31
  todos: join(userDataDir, 'todos.json'),
32
+ reminders: join(userDataDir, 'reminders.json'),
33
+ cronJobs: join(userDataDir, 'cron-jobs.json'),
32
34
  memos: join(userDataDir, 'memos.json'),
33
35
  memoImages: join(userDataDir, 'memo-images'),
34
36
  staticPages: join(userDataDir, 'static-pages.json'),
@@ -0,0 +1,16 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { paths } from '../config.js';
4
+
5
+ export function listCronJobs() {
6
+ try {
7
+ return existsSync(paths.cronJobs) ? JSON.parse(readFileSync(paths.cronJobs, 'utf8')) : [];
8
+ } catch {
9
+ return [];
10
+ }
11
+ }
12
+
13
+ export function saveCronJobs(jobs) {
14
+ mkdirSync(dirname(paths.cronJobs), { recursive: true });
15
+ writeFileSync(paths.cronJobs, JSON.stringify(jobs, null, 2));
16
+ }
@@ -0,0 +1,16 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { paths } from '../config.js';
4
+
5
+ export function listReminders() {
6
+ try {
7
+ return existsSync(paths.reminders) ? JSON.parse(readFileSync(paths.reminders, 'utf8')) : [];
8
+ } catch {
9
+ return [];
10
+ }
11
+ }
12
+
13
+ export function saveReminders(reminders) {
14
+ mkdirSync(dirname(paths.reminders), { recursive: true });
15
+ writeFileSync(paths.reminders, JSON.stringify(reminders, null, 2));
16
+ }
@@ -0,0 +1,157 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { Router } from 'express';
3
+ import { listCronJobs, saveCronJobs } from '../repositories/cron-jobs.js';
4
+ import { listGroupTasks } from '../repositories/group-tasks.js';
5
+ import { listLaunchers } from '../repositories/launchers.js';
6
+ import { readPackageScripts } from '../services/package-scripts.js';
7
+ import { executeCronJob, nextCronJobRun } from '../services/cron-jobs.js';
8
+
9
+ const router = Router();
10
+ const SCHEDULE_TYPES = ['once', 'daily', 'weekdays', 'weekly', 'cron'];
11
+ const TARGET_TYPES = ['custom', 'quick-launch', 'launcher-script'];
12
+
13
+ function validTime(value) {
14
+ const time = String(value || '').trim();
15
+ return /^([01]\d|2[0-3]):[0-5]\d$/.test(time) ? time : null;
16
+ }
17
+
18
+ function validCronExpression(value) {
19
+ const expression = String(value || '').trim();
20
+ return expression && expression.split(/\s+/).length === 5 ? expression : null;
21
+ }
22
+
23
+ function normalizeTarget(body) {
24
+ const targetType = TARGET_TYPES.includes(body?.targetType) ? body.targetType : 'custom';
25
+ const args = String(body?.args || '').trim().slice(0, 1000);
26
+ if (targetType === 'custom') {
27
+ const command = String(body?.command || '').trim().slice(0, 2000);
28
+ const workingDirectory = String(body?.workingDirectory || '').trim();
29
+ if (!command || !workingDirectory) return { error: 'A command and working directory are required.' };
30
+ if (!existsSync(workingDirectory) || !statSync(workingDirectory).isDirectory()) return { error: 'The custom script working directory must be an existing folder.' };
31
+ return { value: { targetType, command, args, workingDirectory } };
32
+ }
33
+ if (targetType === 'quick-launch') {
34
+ const quickLaunchId = String(body?.quickLaunchId || '').trim();
35
+ if (!quickLaunchId) return { error: 'Choose a Quick Launch.' };
36
+ if (!listGroupTasks().some((task) => task.id === quickLaunchId)) return { error: 'The selected Quick Launch does not exist.' };
37
+ return { value: { targetType, args, quickLaunchId } };
38
+ }
39
+ const launcherId = String(body?.launcherId || '').trim();
40
+ const scriptId = String(body?.scriptId || '').trim();
41
+ const launcher = listLaunchers().find((item) => item.id === launcherId);
42
+ if (!launcher) return { error: 'Choose a valid Launcher.' };
43
+ const scripts = [...launcher.scripts, ...readPackageScripts(launcher.folder)];
44
+ if (!scripts.some((script) => script.id === scriptId)) return { error: 'Choose a valid Launcher script.' };
45
+ return { value: { targetType, args, launcherId, scriptId } };
46
+ }
47
+
48
+ function normalizeSchedule(body, now = new Date()) {
49
+ const scheduleType = SCHEDULE_TYPES.includes(body?.scheduleType) ? body.scheduleType : 'daily';
50
+ const enabled = body?.enabled !== false;
51
+ const value = { scheduleType, time: null, weekday: null, scheduledAt: null, cronExpression: null };
52
+ if (scheduleType === 'once') {
53
+ const scheduledAt = new Date(body?.scheduledAt);
54
+ scheduledAt.setSeconds(0, 0);
55
+ if (Number.isNaN(scheduledAt.getTime()) || (enabled && scheduledAt <= now)) return { error: 'Choose a future date and time.' };
56
+ value.scheduledAt = scheduledAt.toISOString();
57
+ } else if (scheduleType === 'cron') {
58
+ value.cronExpression = validCronExpression(body?.cronExpression);
59
+ if (!value.cronExpression) return { error: 'Enter a valid five-field cron expression.' };
60
+ } else {
61
+ value.time = validTime(body?.time);
62
+ if (!value.time) return { error: 'Choose a valid time.' };
63
+ if (scheduleType === 'weekly') {
64
+ value.weekday = Number(body?.weekday);
65
+ if (!Number.isInteger(value.weekday) || value.weekday < 0 || value.weekday > 6) return { error: 'Choose a weekday.' };
66
+ }
67
+ }
68
+ value.nextRunAt = enabled ? nextCronJobRun(value, now) : null;
69
+ if (enabled && !value.nextRunAt) return { error: 'Unable to calculate the next run time.' };
70
+ return { value };
71
+ }
72
+
73
+ function normalizeJob(body, now = new Date()) {
74
+ const name = String(body?.name || '').trim().slice(0, 50);
75
+ if (!name) return { error: 'Cron Job name is required.' };
76
+ const target = normalizeTarget(body);
77
+ if (target.error) return target;
78
+ const schedule = normalizeSchedule(body, now);
79
+ if (schedule.error) return schedule;
80
+ return { value: { name, enabled: body?.enabled !== false, ...target.value, ...schedule.value } };
81
+ }
82
+
83
+ router.get('/', (_req, res) => {
84
+ res.json(listCronJobs().sort((a, b) => Number(b.enabled) - Number(a.enabled) || new Date(a.nextRunAt || 8640000000000000) - new Date(b.nextRunAt || 8640000000000000)));
85
+ });
86
+
87
+ router.get('/catalog', (_req, res) => {
88
+ const launchers = listLaunchers().map((launcher) => ({
89
+ id: launcher.id,
90
+ alias: launcher.alias,
91
+ folder: launcher.folder,
92
+ scripts: [...launcher.scripts.map((script) => ({ id: script.id, name: script.name, source: 'Custom' })), ...readPackageScripts(launcher.folder).map((script) => ({ id: script.id, name: script.name, source: 'Package' }))]
93
+ }));
94
+ const quickLaunches = listGroupTasks().map((task) => ({ id: task.id, name: task.name, scriptCount: task.items.length }));
95
+ res.json({ launchers, quickLaunches });
96
+ });
97
+
98
+ router.post('/', (req, res) => {
99
+ const normalized = normalizeJob(req.body);
100
+ if (normalized.error) return res.status(400).json({ error: normalized.error });
101
+ const now = new Date().toISOString();
102
+ const job = { id: crypto.randomUUID(), ...normalized.value, createdAt: now, updatedAt: now, lastRunAt: null, lastStatus: null, lastError: '' };
103
+ const jobs = listCronJobs();
104
+ jobs.push(job);
105
+ saveCronJobs(jobs);
106
+ res.status(201).json(job);
107
+ });
108
+
109
+ router.put('/:id', (req, res) => {
110
+ const jobs = listCronJobs();
111
+ const index = jobs.findIndex((item) => item.id === req.params.id);
112
+ if (index < 0) return res.status(404).json({ error: 'Cron Job not found.' });
113
+ const normalized = normalizeJob(req.body);
114
+ if (normalized.error) return res.status(400).json({ error: normalized.error });
115
+ const updated = { ...jobs[index], ...normalized.value, updatedAt: new Date().toISOString() };
116
+ jobs[index] = updated;
117
+ saveCronJobs(jobs);
118
+ res.json(updated);
119
+ });
120
+
121
+ router.post('/:id/toggle', (req, res) => {
122
+ const jobs = listCronJobs();
123
+ const index = jobs.findIndex((item) => item.id === req.params.id);
124
+ if (index < 0) return res.status(404).json({ error: 'Cron Job not found.' });
125
+ const job = jobs[index];
126
+ const enabled = req.body?.enabled !== undefined ? Boolean(req.body.enabled) : !job.enabled;
127
+ if (enabled) {
128
+ const nextRunAt = nextCronJobRun(job, new Date());
129
+ if (!nextRunAt || (job.scheduleType === 'once' && new Date(nextRunAt) <= new Date())) return res.status(400).json({ error: 'Edit this Cron Job to choose a future run time.' });
130
+ job.nextRunAt = nextRunAt;
131
+ } else {
132
+ job.nextRunAt = null;
133
+ }
134
+ job.enabled = enabled;
135
+ job.updatedAt = new Date().toISOString();
136
+ jobs[index] = job;
137
+ saveCronJobs(jobs);
138
+ res.json(job);
139
+ });
140
+
141
+ router.post('/:id/run', (req, res) => {
142
+ try {
143
+ res.json(executeCronJob(req.params.id));
144
+ } catch (error) {
145
+ res.status(error.message.includes('already running') ? 409 : 400).json({ error: error.message });
146
+ }
147
+ });
148
+
149
+ router.delete('/:id', (req, res) => {
150
+ const jobs = listCronJobs();
151
+ const next = jobs.filter((item) => item.id !== req.params.id);
152
+ if (next.length === jobs.length) return res.status(404).json({ error: 'Cron Job not found.' });
153
+ saveCronJobs(next);
154
+ res.status(204).end();
155
+ });
156
+
157
+ export default router;
@@ -11,6 +11,19 @@ router.get('/', (_req, res) => res.json(listGroupTasks()));
11
11
  router.get('/catalog', (_req, res) => res.json(listLaunchers().map((launcher) => ({ id: launcher.id, alias: launcher.alias, groupName: launcher.groupName || 'Ungrouped', scripts: [...launcher.scripts.map((script) => ({ id: script.id, name: script.name, source: 'Custom' })), ...readPackageScripts(launcher.folder).map((script) => ({ id: script.id, name: script.name, source: 'Package' }))] }))));
12
12
  router.post('/', (req, res) => { const task = validTask(req.body); if (!task) return res.status(400).json({ error: 'A name and at least one script are required.' }); const created = { id: crypto.randomUUID(), ...task }; saveGroupTasks([...listGroupTasks(), created]); res.status(201).json(created); });
13
13
  router.put('/:id', (req, res) => { const task = validTask(req.body); if (!task) return res.status(400).json({ error: 'A name and at least one script are required.' }); const tasks = listGroupTasks(); const index = tasks.findIndex((item) => item.id === req.params.id); if (index < 0) return res.status(404).json({ error: 'Group task not found.' }); const updated = { id: req.params.id, ...task }; tasks[index] = updated; saveGroupTasks(tasks); res.json(updated); });
14
+ router.post('/:id/move', (req, res) => {
15
+ const direction = req.body?.direction;
16
+ if (direction !== 'up' && direction !== 'down') return res.status(400).json({ error: 'A move direction is required.' });
17
+ const tasks = listGroupTasks();
18
+ const index = tasks.findIndex((item) => item.id === req.params.id);
19
+ if (index < 0) return res.status(404).json({ error: 'Group task not found.' });
20
+ const targetIndex = direction === 'up' ? index - 1 : index + 1;
21
+ if (targetIndex >= 0 && targetIndex < tasks.length) {
22
+ [tasks[index], tasks[targetIndex]] = [tasks[targetIndex], tasks[index]];
23
+ saveGroupTasks(tasks);
24
+ }
25
+ res.json(tasks);
26
+ });
14
27
  router.delete('/:id', (req, res) => { const tasks = listGroupTasks(); const next = tasks.filter((item) => item.id !== req.params.id); if (next.length === tasks.length) return res.status(404).json({ error: 'Group task not found.' }); saveGroupTasks(next); res.status(204).end(); });
15
28
  router.post('/:id/start', (req, res) => { const task = listGroupTasks().find((item) => item.id === req.params.id); if (!task) return res.status(404).json({ error: 'Group task not found.' }); const launchers = listLaunchers(); const results = task.items.map((item) => { const launcher = launchers.find((entry) => entry.id === item.launcherId); if (!launcher) return { ...item, error: 'Service not found.' }; const custom = launcher.scripts.find((script) => script.id === item.scriptId); const packageScript = custom ? null : readPackageScripts(launcher.folder).find((script) => script.id === item.scriptId); if (!custom && !packageScript) return { ...item, error: 'Script not found.' }; try { runScript(launcher, custom || { ...packageScript, command: `${launcher.executor} run ${packageScript.name}` }); return { ...item, ok: true }; } catch (error) { return { ...item, error: error.message }; } }); res.json({ results }); });
16
29
  router.post('/:id/stop', (req, res) => { const task = listGroupTasks().find((item) => item.id === req.params.id); if (!task) return res.status(404).json({ error: 'Group task not found.' }); const results = task.items.map((item) => { try { stopScript(item.launcherId, item.scriptId); return { ...item, ok: true }; } catch (error) { return { ...item, error: error.message }; } }); res.json({ results }); });
@@ -0,0 +1,167 @@
1
+ import { Router } from 'express';
2
+ import { listReminders, saveReminders } from '../repositories/reminders.js';
3
+
4
+ const router = Router();
5
+ const SCHEDULE_TYPES = ['once', 'daily', 'weekdays', 'weekly'];
6
+
7
+ function validTime(value) {
8
+ const time = String(value || '').trim();
9
+ if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) return null;
10
+ return time;
11
+ }
12
+
13
+ function setLocalTime(date, time) {
14
+ const [hours, minutes] = time.split(':').map(Number);
15
+ const next = new Date(date);
16
+ next.setHours(hours, minutes, 0, 0);
17
+ return next;
18
+ }
19
+
20
+ function nextRepeatingRun(reminder, now = new Date()) {
21
+ const time = validTime(reminder.time);
22
+ if (!time) return null;
23
+ const candidate = setLocalTime(now, time);
24
+
25
+ if (reminder.scheduleType === 'daily') {
26
+ if (candidate <= now) candidate.setDate(candidate.getDate() + 1);
27
+ return candidate.toISOString();
28
+ }
29
+
30
+ if (reminder.scheduleType === 'weekdays') {
31
+ if (candidate <= now) candidate.setDate(candidate.getDate() + 1);
32
+ while ([0, 6].includes(candidate.getDay())) candidate.setDate(candidate.getDate() + 1);
33
+ return candidate.toISOString();
34
+ }
35
+
36
+ const weekday = Number(reminder.weekday);
37
+ if (!Number.isInteger(weekday) || weekday < 0 || weekday > 6) return null;
38
+ let daysAhead = (weekday - candidate.getDay() + 7) % 7;
39
+ if (daysAhead === 0 && candidate <= now) daysAhead = 7;
40
+ candidate.setDate(candidate.getDate() + daysAhead);
41
+ return candidate.toISOString();
42
+ }
43
+
44
+ function normalizeReminder(body, now = new Date()) {
45
+ const title = String(body?.title || '').trim().slice(0, 50);
46
+ const content = String(body?.content || '').trim().slice(0, 100);
47
+ const scheduleType = SCHEDULE_TYPES.includes(body?.scheduleType) ? body.scheduleType : 'once';
48
+ if (!title) return { error: 'Reminder title is required.' };
49
+ if (!content) return { error: 'Notification content is required.' };
50
+
51
+ const normalized = {
52
+ title,
53
+ content,
54
+ scheduleType,
55
+ enabled: body?.enabled !== false,
56
+ time: null,
57
+ weekday: null,
58
+ scheduledAt: null
59
+ };
60
+
61
+ if (scheduleType === 'once') {
62
+ const scheduledAt = new Date(body?.scheduledAt);
63
+ if (Number.isNaN(scheduledAt.getTime())) return { error: 'Choose a valid reminder date and time.' };
64
+ scheduledAt.setSeconds(0, 0);
65
+ if (normalized.enabled && scheduledAt <= now) return { error: 'Reminder date and time must be in the future.' };
66
+ normalized.scheduledAt = scheduledAt.toISOString();
67
+ normalized.nextRunAt = normalized.enabled ? normalized.scheduledAt : null;
68
+ return { value: normalized };
69
+ }
70
+
71
+ normalized.time = validTime(body?.time);
72
+ if (!normalized.time) return { error: 'Choose a valid reminder time.' };
73
+ if (scheduleType === 'weekly') {
74
+ const weekday = Number(body?.weekday);
75
+ if (!Number.isInteger(weekday) || weekday < 0 || weekday > 6) return { error: 'Choose a weekday.' };
76
+ normalized.weekday = weekday;
77
+ }
78
+ normalized.nextRunAt = normalized.enabled ? nextRepeatingRun(normalized, now) : null;
79
+ if (!normalized.nextRunAt) return { error: 'Unable to calculate the next reminder time.' };
80
+ return { value: normalized };
81
+ }
82
+
83
+ router.get('/', (_req, res) => {
84
+ res.json(listReminders().sort((a, b) => {
85
+ if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
86
+ return new Date(a.nextRunAt || 8640000000000000) - new Date(b.nextRunAt || 8640000000000000);
87
+ }));
88
+ });
89
+
90
+ router.post('/claim-due', (req, res) => {
91
+ const now = new Date(req.body?.now || Date.now());
92
+ if (Number.isNaN(now.getTime())) return res.status(400).json({ error: 'Invalid current time.' });
93
+ const reminders = listReminders();
94
+ const due = [];
95
+ let changed = false;
96
+
97
+ reminders.forEach((reminder) => {
98
+ if (!reminder.enabled || !reminder.nextRunAt || new Date(reminder.nextRunAt) > now) return;
99
+ due.push({ id: reminder.id, title: reminder.title, content: reminder.content });
100
+ reminder.lastTriggeredAt = now.toISOString();
101
+ if (reminder.scheduleType === 'once') {
102
+ reminder.enabled = false;
103
+ reminder.nextRunAt = null;
104
+ } else {
105
+ reminder.nextRunAt = nextRepeatingRun(reminder, new Date(now.getTime() + 1000));
106
+ }
107
+ reminder.updatedAt = now.toISOString();
108
+ changed = true;
109
+ });
110
+
111
+ if (changed) saveReminders(reminders);
112
+ res.json({ due });
113
+ });
114
+
115
+ router.post('/', (req, res) => {
116
+ const normalized = normalizeReminder(req.body);
117
+ if (normalized.error) return res.status(400).json({ error: normalized.error });
118
+ const now = new Date().toISOString();
119
+ const reminder = { id: crypto.randomUUID(), ...normalized.value, createdAt: now, updatedAt: now };
120
+ const reminders = listReminders();
121
+ reminders.push(reminder);
122
+ saveReminders(reminders);
123
+ res.status(201).json(reminder);
124
+ });
125
+
126
+ router.put('/:id', (req, res) => {
127
+ const reminders = listReminders();
128
+ const index = reminders.findIndex((item) => item.id === req.params.id);
129
+ if (index < 0) return res.status(404).json({ error: 'Reminder not found.' });
130
+ const normalized = normalizeReminder(req.body);
131
+ if (normalized.error) return res.status(400).json({ error: normalized.error });
132
+ const now = new Date().toISOString();
133
+ const updated = { ...reminders[index], ...normalized.value, updatedAt: now };
134
+ reminders[index] = updated;
135
+ saveReminders(reminders);
136
+ res.json(updated);
137
+ });
138
+
139
+ router.post('/:id/toggle', (req, res) => {
140
+ const reminders = listReminders();
141
+ const index = reminders.findIndex((item) => item.id === req.params.id);
142
+ if (index < 0) return res.status(404).json({ error: 'Reminder not found.' });
143
+ const reminder = reminders[index];
144
+ const enabled = req.body?.enabled !== undefined ? Boolean(req.body.enabled) : !reminder.enabled;
145
+ const now = new Date();
146
+ if (enabled && reminder.scheduleType === 'once' && (!reminder.scheduledAt || new Date(reminder.scheduledAt) <= now)) {
147
+ return res.status(400).json({ error: 'This one-time reminder has passed. Edit its date before enabling it again.' });
148
+ }
149
+ reminder.enabled = enabled;
150
+ reminder.nextRunAt = enabled
151
+ ? (reminder.scheduleType === 'once' ? reminder.scheduledAt : nextRepeatingRun(reminder, now))
152
+ : null;
153
+ reminder.updatedAt = now.toISOString();
154
+ reminders[index] = reminder;
155
+ saveReminders(reminders);
156
+ res.json(reminder);
157
+ });
158
+
159
+ router.delete('/:id', (req, res) => {
160
+ const reminders = listReminders();
161
+ const next = reminders.filter((item) => item.id !== req.params.id);
162
+ if (next.length === reminders.length) return res.status(404).json({ error: 'Reminder not found.' });
163
+ saveReminders(next);
164
+ res.status(204).end();
165
+ });
166
+
167
+ export default router;