buddy-workbench 0.1.0

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/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # DevBuddy Workbench
2
+
3
+ A minimal local workbench. Express serves both the interface and API, and manages local project scripts.
4
+
5
+ ## Run
6
+
7
+ ```bash
8
+ npm install
9
+ npm run build
10
+ npm start
11
+ ```
12
+
13
+ Open `http://localhost:3100`. To use another port, run `PORT=4000 npm start`. Startup attempts to stop any process currently using the selected port.
14
+
15
+ ## UI development
16
+
17
+ The UI is an independent React + Vite project in `ui/`, built with Ant Design. Run `npm run build` to build it, then run `npm start` to start Express. For UI development, use `npm run dev` in one terminal and `npm run dev:ui` in another; Vite proxies API and plugin requests to Express.
18
+
19
+ ## Plugins
20
+
21
+ Create a manifest at `plugins/<plugin-name>/plugin.json` to add an item to the sidebar:
22
+
23
+ ```json
24
+ { "name": "My feature", "icon": "✦", "view": "view.html" }
25
+ ```
26
+
27
+ Plugins are discovered automatically. `view` points to an HTML fragment in the plugin directory. See `plugins/example`; place your HTML, scripts, and styles in an isolated plugin folder. Add API routes as needed for server-side capabilities.
28
+
29
+ ## Scripts and logs
30
+
31
+ Each service can have multiple named scripts. Run, stop, and view the last 1,000 lines of output for each script from the launcher. Logs are retained in memory until the workbench is restarted and refresh in the browser every second while the log window is open.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "buddy-workbench",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "bin": {
7
+ "buddy-workbench": "./server.js"
8
+ },
9
+ "files": [
10
+ "server.js",
11
+ "server/",
12
+ "ui/dist/",
13
+ "plugins/"
14
+ ],
15
+ "scripts": {
16
+ "start": "exec node server.js",
17
+ "dev": "node --watch server.js",
18
+ "build": "npm --prefix ui run build",
19
+ "dev:ui": "npm --prefix ui run dev",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "dependencies": {
23
+ "express": "^5.1.0"
24
+ }
25
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "Example plugin",
3
+ "icon": "▦",
4
+ "view": "view.html"
5
+ }
@@ -0,0 +1,5 @@
1
+ <div class="empty">
2
+ <div>▦</div>
3
+ <h2>Example plugin</h2>
4
+ <p>Replace this file with the HTML view for your feature.</p>
5
+ </div>
@@ -0,0 +1,14 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ export const root = dirname(dirname(fileURLToPath(import.meta.url)));
5
+ export const paths = {
6
+ launchers: join(root, 'data', 'launchers.json'),
7
+ portHistory: join(root, 'data', 'port-history.json'),
8
+ groupTasks: join(root, 'data', 'group-tasks.json'),
9
+ settings: join(root, 'data', 'settings.json'),
10
+ shutdownLog: join(root, 'data', 'shutdown.log'),
11
+ clipboardDir: join(root, 'data', 'clipboard'),
12
+ plugins: join(root, 'plugins'),
13
+ ui: join(root, 'ui', 'dist')
14
+ };
@@ -0,0 +1,27 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+ const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
6
+
7
+ async function portPids(port) {
8
+ const { stdout } = await execFileAsync('lsof', ['-ti', `:${port}`]);
9
+ return [...new Set(stdout.trim().split(/\s+/).filter(Boolean))];
10
+ }
11
+
12
+ export async function freePort(port) {
13
+ try {
14
+ if (process.platform === 'win32') {
15
+ const { stdout } = await execFileAsync('netstat', ['-ano', '-p', 'tcp']);
16
+ const pids = [...stdout.matchAll(new RegExp(`:${port}\\s+.*?LISTENING\\s+(\\d+)`, 'g'))].map((match) => match[1]);
17
+ await Promise.all([...new Set(pids)].map((pid) => execFileAsync('taskkill', ['/F', '/PID', pid])));
18
+ } else {
19
+ const pids = await portPids(port);
20
+ await Promise.all(pids.map((pid) => execFileAsync('kill', ['-TERM', pid])));
21
+ await wait(1800);
22
+ await Promise.all((await portPids(port)).map((pid) => execFileAsync('kill', ['-KILL', pid])));
23
+ }
24
+ } catch (error) {
25
+ if (error.code !== 1) console.warn(`Could not fully free port ${port}:`, error.message);
26
+ }
27
+ }
@@ -0,0 +1,6 @@
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 listGroupTasks() { try { return existsSync(paths.groupTasks) ? JSON.parse(readFileSync(paths.groupTasks, 'utf8')) : []; } catch { return []; } }
6
+ export function saveGroupTasks(tasks) { mkdirSync(dirname(paths.groupTasks), { recursive: true }); writeFileSync(paths.groupTasks, JSON.stringify(tasks, null, 2)); }
@@ -0,0 +1,19 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { paths } from '../config.js';
4
+
5
+ function normalize(launcher) {
6
+ const scripts = launcher.scripts || [{ id: 'default', name: 'Default', command: launcher.command || '', startCommand: true }];
7
+ const startCommand = typeof launcher.startCommand === 'string' ? launcher.startCommand : scripts.find((script) => script.startCommand)?.name || '';
8
+ return { ...launcher, groupName: typeof launcher.groupName === 'string' ? launcher.groupName : '', executor: typeof launcher.executor === 'string' && launcher.executor.trim() ? launcher.executor.trim() : 'npm', startCommand, scripts: scripts.map(({ startCommand: _legacyStartCommand, ...script }) => script) };
9
+ }
10
+
11
+ export function listLaunchers() {
12
+ if (!existsSync(paths.launchers)) return [];
13
+ try { return JSON.parse(readFileSync(paths.launchers, 'utf8')).map(normalize); } catch { return []; }
14
+ }
15
+
16
+ export function saveLaunchers(launchers) {
17
+ mkdirSync(dirname(paths.launchers), { recursive: true });
18
+ writeFileSync(paths.launchers, JSON.stringify(launchers, null, 2));
19
+ }
@@ -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 listPortHistory() {
6
+ if (!existsSync(paths.portHistory)) return [];
7
+ try {
8
+ const records = JSON.parse(readFileSync(paths.portHistory, 'utf8'));
9
+ return Array.isArray(records) ? records.filter((item) => Number.isInteger(item?.port) && item.port > 0 && item.port <= 65535) : [];
10
+ } catch { return []; }
11
+ }
12
+
13
+ export function savePortHistory(records) {
14
+ mkdirSync(dirname(paths.portHistory), { recursive: true });
15
+ writeFileSync(paths.portHistory, JSON.stringify(records, null, 2));
16
+ }
@@ -0,0 +1,44 @@
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 readSettings() {
6
+ if (!existsSync(paths.settings)) return {};
7
+ try { return JSON.parse(readFileSync(paths.settings, 'utf8')); } catch { return {}; }
8
+ }
9
+
10
+ export function settingsStatus() {
11
+ const settings = readSettings();
12
+ return {
13
+ domain: typeof settings.domain === 'string' ? settings.domain : '',
14
+ bitbucketTokenConfigured: Boolean(settings.bitbucketAccessToken),
15
+ jiraTokenConfigured: Boolean(settings.jiraAccessToken),
16
+ confluenceTokenConfigured: Boolean(settings.confluenceAccessToken)
17
+ };
18
+ }
19
+
20
+ export function saveDomain(domain) {
21
+ const settings = readSettings();
22
+ if (domain) settings.domain = domain;
23
+ else delete settings.domain;
24
+ mkdirSync(dirname(paths.settings), { recursive: true });
25
+ writeFileSync(paths.settings, JSON.stringify(settings, null, 2), { mode: 0o600 });
26
+ return settingsStatus();
27
+ }
28
+
29
+ const accessTokenFields = {
30
+ bitbucket: 'bitbucketAccessToken',
31
+ jira: 'jiraAccessToken',
32
+ confluence: 'confluenceAccessToken'
33
+ };
34
+
35
+ export function saveAccessToken(service, token) {
36
+ const field = accessTokenFields[service];
37
+ if (!field) throw new Error('Unsupported token service.');
38
+ const settings = readSettings();
39
+ if (token) settings[field] = token;
40
+ else delete settings[field];
41
+ mkdirSync(dirname(paths.settings), { recursive: true });
42
+ writeFileSync(paths.settings, JSON.stringify(settings, null, 2), { mode: 0o600 });
43
+ return settingsStatus();
44
+ }
@@ -0,0 +1,8 @@
1
+ import { Router } from 'express';
2
+ import { clipboardDates, clipboardItems, clipboardOriginal, deleteClipboardItem } from '../services/clipboard-history.js';
3
+
4
+ const router = Router();
5
+ router.get('/', (req, res) => res.json({ dates: clipboardDates(), items: clipboardItems(req.query.date) }));
6
+ router.get('/:date/:id', (req, res) => { const text = clipboardOriginal(req.params.date, req.params.id); if (text === null) return res.status(404).json({ error: 'Clipboard entry not found.' }); res.type('text/plain').send(text); });
7
+ 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(); });
8
+ export default router;
@@ -0,0 +1,17 @@
1
+ import { Router } from 'express';
2
+ import { listLaunchers } from '../repositories/launchers.js';
3
+ import { listGroupTasks, saveGroupTasks } from '../repositories/group-tasks.js';
4
+ import { runScript, stopScript } from '../services/process-manager.js';
5
+ import { readPackageScripts } from '../services/package-scripts.js';
6
+
7
+ const router = Router();
8
+ function validTask(body) { const name = String(body?.name || '').trim(); const items = Array.isArray(body?.items) ? body.items.filter((item) => item?.launcherId && item?.scriptId).map((item) => ({ launcherId: item.launcherId, scriptId: item.scriptId })) : []; return name && items.length ? { name, items } : null; }
9
+
10
+ router.get('/', (_req, res) => res.json(listGroupTasks()));
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
+ 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
+ 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.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
+ 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
+ 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 }); });
17
+ export default router;
@@ -0,0 +1,47 @@
1
+ import { Router } from 'express';
2
+ import { listLaunchers, saveLaunchers } from '../repositories/launchers.js';
3
+ import { runScript, runningScripts, scriptErrorLogs, scriptLogs, stopScript } from '../services/process-manager.js';
4
+ import { readPackageScripts } from '../services/package-scripts.js';
5
+ import { currentGitBranch } from '../services/git.js';
6
+
7
+ const router = Router();
8
+ function validLauncher(body) {
9
+ const { alias, folder, scripts, startCommand, groupName, executor } = body || {};
10
+ if (![alias, folder].every((value) => typeof value === 'string' && value.trim()) || (scripts !== undefined && !Array.isArray(scripts))) return null;
11
+ const normalizedScripts = (scripts || []).filter(Boolean).map((script) => ({ id: script.id || crypto.randomUUID(), name: String(script.name || '').trim(), command: String(script.command || '').trim() }));
12
+ if (normalizedScripts.some((script) => !script.name || !script.command)) return null;
13
+ const normalizedStartCommand = String(startCommand || '').trim();
14
+ return { alias: alias.trim(), folder: folder.trim(), groupName: String(groupName || '').trim(), executor: String(executor || 'npm').trim() || 'npm', startCommand: normalizedStartCommand, scripts: normalizedScripts };
15
+ }
16
+ const invalid = (res) => res.status(400).json({ error: 'Name, project folder, and any custom scripts must be complete.' });
17
+
18
+ router.get('/', async (_req, res) => {
19
+ const launchers = listLaunchers();
20
+ const items = await Promise.all(launchers.map(async (launcher) => ({ ...launcher, packageScriptCount: readPackageScripts(launcher.folder).length, branch: await currentGitBranch(launcher.folder) })));
21
+ res.json(items);
22
+ });
23
+ router.post('/', (req, res) => { const config = validLauncher(req.body); if (!config) return invalid(res); const launcher = { id: crypto.randomUUID(), ...config }; const launchers = listLaunchers(); saveLaunchers([...launchers, launcher]); res.status(201).json(launcher); });
24
+ router.put('/:id', (req, res) => { const config = validLauncher(req.body); if (!config) return invalid(res); const launchers = listLaunchers(); const index = launchers.findIndex((item) => item.id === req.params.id); if (index === -1) return res.status(404).json({ error: 'Configuration not found.' }); const launcher = { id: req.params.id, ...config }; launchers[index] = launcher; saveLaunchers(launchers); res.json(launcher); });
25
+ router.delete('/:id', (req, res) => { const launchers = listLaunchers(); const next = launchers.filter((item) => item.id !== req.params.id); if (next.length === launchers.length) return res.status(404).json({ error: 'Configuration not found.' }); saveLaunchers(next); res.status(204).end(); });
26
+ router.get('/running', (_req, res) => res.json(runningScripts()));
27
+ router.post('/:id/stop', (req, res) => { try { stopScript(req.params.id, req.body?.scriptId); res.json({ ok: true }); } catch (error) { res.status(404).json({ error: error.message }); } });
28
+ router.post('/:id/start', (req, res) => {
29
+ const launcher = listLaunchers().find((item) => item.id === req.params.id);
30
+ if (!launcher) return res.status(404).json({ error: 'Configuration not found.' });
31
+ const customScript = launcher.scripts.find((item) => item.name === launcher.startCommand);
32
+ const packageScript = customScript ? null : readPackageScripts(launcher.folder).find((item) => item.name === launcher.startCommand);
33
+ if (!customScript && !packageScript) return res.status(400).json({ error: 'No valid start command is configured.' });
34
+ try { runScript(launcher, customScript || { ...packageScript, command: `${launcher.executor} run ${packageScript.name}` }); res.json({ ok: true }); }
35
+ catch (error) { res.status(error.message.includes('already') ? 409 : 400).json({ error: error.message }); }
36
+ });
37
+ router.get('/:id/package-scripts', (req, res) => { const launcher = listLaunchers().find((item) => item.id === req.params.id); if (!launcher) return res.status(404).json({ error: 'Configuration not found.' }); res.json({ scripts: readPackageScripts(launcher.folder) }); });
38
+ router.post('/:id/install/run', (req, res) => { const launcher = listLaunchers().find((item) => item.id === req.params.id); if (!launcher) return res.status(404).json({ error: 'Configuration not found.' }); if (!['npm', 'pnpm', 'yarn'].includes(launcher.executor)) return res.status(400).json({ error: 'Install is only available for npm, pnpm, or yarn.' }); const command = `${launcher.executor} install${launcher.executor === 'npm' ? ' --legacy-peer-deps' : ''}`; try { runScript(launcher, { id: 'install', name: 'Install', command }); res.json({ ok: true }); } catch (error) { res.status(error.message.includes('already') ? 409 : 400).json({ error: error.message }); } });
39
+ router.post('/:id/package-scripts/:scriptName/run', (req, res) => { const launcher = listLaunchers().find((item) => item.id === req.params.id); if (!launcher) return res.status(404).json({ error: 'Configuration not found.' }); const script = readPackageScripts(launcher.folder).find((item) => item.name === req.params.scriptName); if (!script) return res.status(404).json({ error: 'Package script not found.' }); try { runScript(launcher, { ...script, command: `${launcher.executor} run ${script.name}` }); res.json({ ok: true }); } catch (error) { res.status(error.message.includes('already') ? 409 : 400).json({ error: error.message }); } });
40
+ router.get('/:id/package-scripts/:scriptName/logs', (req, res) => { const script = readPackageScripts(listLaunchers().find((item) => item.id === req.params.id)?.folder || '').find((item) => item.name === req.params.scriptName); if (!script) return res.status(404).json({ error: 'Package script not found.' }); res.json({ log: scriptLogs(req.params.id, script.id) }); });
41
+ router.get('/:id/package-scripts/:scriptName/logs/error', (req, res) => { const script = readPackageScripts(listLaunchers().find((item) => item.id === req.params.id)?.folder || '').find((item) => item.name === req.params.scriptName); if (!script) return res.status(404).json({ error: 'Package script not found.' }); res.json({ log: scriptErrorLogs(req.params.id, script.id) }); });
42
+ router.get('/:id/scripts/:scriptId/logs', (req, res) => res.json({ log: scriptLogs(req.params.id, req.params.scriptId) }));
43
+ router.get('/:id/scripts/:scriptId/logs/error', (req, res) => res.json({ log: scriptErrorLogs(req.params.id, req.params.scriptId) }));
44
+ router.post('/:id/scripts/:scriptId/run', (req, res) => { const launcher = listLaunchers().find((item) => item.id === req.params.id); if (!launcher) return res.status(404).json({ error: 'Configuration not found.' }); const script = launcher.scripts.find((item) => item.id === req.params.scriptId); if (!script) return res.status(404).json({ error: 'Script not found.' }); try { runScript(launcher, script); res.json({ ok: true }); } catch (error) { res.status(error.message.includes('already') ? 409 : 400).json({ error: error.message }); } });
45
+ router.post('/:id/scripts/:scriptId/stop', (req, res) => { try { stopScript(req.params.id, req.params.scriptId); res.json({ ok: true }); } catch (error) { res.status(404).json({ error: error.message }); } });
46
+
47
+ export default router;
@@ -0,0 +1,6 @@
1
+ import { Router } from 'express';
2
+ import { listPlugins } from '../services/plugins.js';
3
+
4
+ const router = Router();
5
+ router.get('/', (_req, res) => res.json(listPlugins()));
6
+ export default router;
@@ -0,0 +1,25 @@
1
+ import { Router } from 'express';
2
+ import { killPortProcess, portDiagnostics, portProcess } from '../services/process-manager.js';
3
+
4
+ const router = Router();
5
+
6
+ router.get('/', async (_req, res) => {
7
+ try { res.json({ ports: await portDiagnostics() }); }
8
+ catch (error) { res.status(500).json({ error: error.message }); }
9
+ });
10
+
11
+ router.get('/:port', async (req, res) => {
12
+ const port = Number(req.params.port);
13
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return res.status(400).json({ error: 'A valid port is required.' });
14
+ try { res.json({ process: await portProcess(port) }); }
15
+ catch (error) { res.status(500).json({ error: error.message }); }
16
+ });
17
+
18
+ router.post('/kill', async (req, res) => {
19
+ const port = Number(req.body?.port); const pid = Number(req.body?.pid);
20
+ if (!Number.isInteger(port) || port < 1 || port > 65535 || !Number.isInteger(pid) || pid < 1) return res.status(400).json({ error: 'A valid port and process ID are required.' });
21
+ try { await killPortProcess(port, pid); res.json({ ok: true }); }
22
+ catch (error) { res.status(404).json({ error: error.message }); }
23
+ });
24
+
25
+ export default router;