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 +31 -0
- package/package.json +25 -0
- package/plugins/example/plugin.json +5 -0
- package/plugins/example/view.html +5 -0
- package/server/config.js +14 -0
- package/server/lib/free-port.js +27 -0
- package/server/repositories/group-tasks.js +6 -0
- package/server/repositories/launchers.js +19 -0
- package/server/repositories/port-history.js +16 -0
- package/server/repositories/settings.js +44 -0
- package/server/routes/clipboard.js +8 -0
- package/server/routes/group-tasks.js +17 -0
- package/server/routes/launchers.js +47 -0
- package/server/routes/plugins.js +6 -0
- package/server/routes/port-diagnostics.js +25 -0
- package/server/routes/pr-review.js +378 -0
- package/server/routes/settings.js +18 -0
- package/server/services/clipboard-history.js +85 -0
- package/server/services/git.js +14 -0
- package/server/services/package-scripts.js +11 -0
- package/server/services/plugins.js +15 -0
- package/server/services/process-manager.js +195 -0
- package/server/services/script-supervisor.js +28 -0
- package/server.js +55 -0
- package/ui/dist/assets/index-CCCOP2nr.js +496 -0
- package/ui/dist/assets/index-Dty-56mC.js +3 -0
- package/ui/dist/assets/index-VPzoCQox.css +1 -0
- package/ui/dist/assets/mozjpeg_dec-muSO2n8T.wasm +0 -0
- package/ui/dist/assets/mozjpeg_enc-DO-zoExo.wasm +0 -0
- package/ui/dist/devbuddy.svg +29 -0
- package/ui/dist/index.html +16 -0
- package/ui/dist/manifest.webmanifest +17 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { listPortHistory, savePortHistory } from '../repositories/port-history.js';
|
|
7
|
+
|
|
8
|
+
const running = new Map();
|
|
9
|
+
const logs = new Map();
|
|
10
|
+
const stoppingGroups = new Set();
|
|
11
|
+
const processGroups = new Set();
|
|
12
|
+
const launchHistory = [];
|
|
13
|
+
const savedPortHistory = new Map(listPortHistory().map((record) => [record.port, record]));
|
|
14
|
+
const supervisorPath = fileURLToPath(new URL('./script-supervisor.js', import.meta.url));
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const keyFor = (launcherId, scriptId) => `${launcherId}:${scriptId}`;
|
|
17
|
+
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');
|
|
20
|
+
logs.set(key, current);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function runningScripts() { return [...running.keys()]; }
|
|
24
|
+
export function scriptLogs(launcherId, scriptId) { return logs.get(keyFor(launcherId, scriptId))?.output || ''; }
|
|
25
|
+
export function scriptErrorLogs(launcherId, scriptId) { return logs.get(keyFor(launcherId, scriptId))?.error || ''; }
|
|
26
|
+
|
|
27
|
+
async function listeningProcesses() {
|
|
28
|
+
if (process.platform === 'win32') return [];
|
|
29
|
+
try {
|
|
30
|
+
const { stdout } = await execFileAsync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn'], { maxBuffer: 1024 * 1024 });
|
|
31
|
+
const rows = [];
|
|
32
|
+
let current = {};
|
|
33
|
+
for (const line of stdout.split('\n')) {
|
|
34
|
+
if (!line) continue;
|
|
35
|
+
const type = line[0]; const value = line.slice(1);
|
|
36
|
+
if (type === 'p') current = { pid: Number(value) };
|
|
37
|
+
else if (type === 'c') current.command = value;
|
|
38
|
+
else if (type === 'n') {
|
|
39
|
+
const match = value.match(/:(\d+)(?:\s|$)/);
|
|
40
|
+
if (match && current.pid) rows.push({ ...current, port: Number(match[1]) });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return rows.filter((item) => item.pid && item.port);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error.code === 1) return [];
|
|
46
|
+
throw new Error('Unable to inspect listening ports. Ensure lsof is available.');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function processGroupFor(pid) {
|
|
51
|
+
try {
|
|
52
|
+
const { stdout } = await execFileAsync('ps', ['-o', 'pgid=', '-p', String(pid)]);
|
|
53
|
+
return Number(stdout.trim());
|
|
54
|
+
} catch { return null; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function observedListeners() {
|
|
58
|
+
const listeners = await listeningProcesses();
|
|
59
|
+
const groups = await Promise.all(listeners.map(async (listener) => [listener.pid, await processGroupFor(listener.pid)]));
|
|
60
|
+
return { listeners, groupByPid: new Map(groups) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function observeLauncherPorts() {
|
|
64
|
+
if (!launchHistory.length) return;
|
|
65
|
+
const { listeners, groupByPid } = await observedListeners();
|
|
66
|
+
const now = new Date().toISOString();
|
|
67
|
+
let changed = false;
|
|
68
|
+
for (const listener of listeners) {
|
|
69
|
+
const launch = launchHistory.find((item) => item.groupPid === groupByPid.get(listener.pid));
|
|
70
|
+
if (!launch) continue;
|
|
71
|
+
const previous = launch.ports.get(listener.port);
|
|
72
|
+
const record = { port: listener.port, pid: listener.pid, command: listener.command || 'unknown', firstSeenAt: previous?.firstSeenAt || now, lastSeenAt: now };
|
|
73
|
+
launch.ports.set(listener.port, record);
|
|
74
|
+
savedPortHistory.set(listener.port, { ...record, launcher: launch.launcher, script: launch.script, startedAt: launch.startedAt });
|
|
75
|
+
changed ||= !previous || previous.pid !== record.pid || previous.command !== record.command;
|
|
76
|
+
}
|
|
77
|
+
if (changed) savePortHistory([...savedPortHistory.values()].sort((a, b) => a.port - b.port));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
setInterval(() => { void observeLauncherPorts().catch(() => {}); }, 1000).unref();
|
|
81
|
+
|
|
82
|
+
export async function portDiagnostics() {
|
|
83
|
+
await observeLauncherPorts();
|
|
84
|
+
const { listeners, groupByPid } = await observedListeners();
|
|
85
|
+
const owned = new Map(listeners.map((item) => [`${groupByPid.get(item.pid)}:${item.port}`, item]));
|
|
86
|
+
const byPort = new Map(listeners.map((item) => [item.port, item]));
|
|
87
|
+
const records = launchHistory.flatMap((launch) => [...launch.ports.values()].map((record) => {
|
|
88
|
+
const listener = owned.get(`${launch.groupPid}:${record.port}`);
|
|
89
|
+
const occupyingProcess = byPort.get(record.port);
|
|
90
|
+
const scriptRunning = running.get(launch.key)?.pid === launch.groupPid;
|
|
91
|
+
return {
|
|
92
|
+
...record,
|
|
93
|
+
launcher: launch.launcher,
|
|
94
|
+
script: launch.script,
|
|
95
|
+
startedAt: launch.startedAt,
|
|
96
|
+
scriptRunning,
|
|
97
|
+
listening: Boolean(listener),
|
|
98
|
+
ghost: Boolean(listener) && !scriptRunning,
|
|
99
|
+
reused: !listener && Boolean(occupyingProcess),
|
|
100
|
+
currentPid: listener?.pid,
|
|
101
|
+
currentCommand: listener?.command
|
|
102
|
+
};
|
|
103
|
+
}));
|
|
104
|
+
const latestByPort = new Map();
|
|
105
|
+
for (const record of records) {
|
|
106
|
+
const existing = latestByPort.get(record.port);
|
|
107
|
+
if (!existing || (record.listening && !existing.listening) || record.startedAt > existing.startedAt) latestByPort.set(record.port, record);
|
|
108
|
+
}
|
|
109
|
+
for (const [port, record] of savedPortHistory) {
|
|
110
|
+
if (latestByPort.has(port)) continue;
|
|
111
|
+
const occupyingProcess = byPort.get(port);
|
|
112
|
+
latestByPort.set(port, { ...record, listening: false, ghost: false, reused: Boolean(occupyingProcess), currentPid: occupyingProcess?.pid, currentCommand: occupyingProcess?.command });
|
|
113
|
+
}
|
|
114
|
+
return [...latestByPort.values()].sort((a, b) => a.port - b.port);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function portProcess(port) {
|
|
118
|
+
const listener = (await listeningProcesses()).find((item) => item.port === Number(port));
|
|
119
|
+
return listener ? { port: listener.port, pid: listener.pid, command: listener.command || 'unknown' } : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function killPortProcess(port, pid) {
|
|
123
|
+
const target = await portProcess(port);
|
|
124
|
+
if (target && target.pid !== Number(pid)) throw new Error('This port is now used by a different process. Refresh and try again.');
|
|
125
|
+
if (!target) throw new Error('This port is no longer active. Refresh and try again.');
|
|
126
|
+
try { process.kill(target.pid, 'SIGKILL'); }
|
|
127
|
+
catch (error) { throw new Error(error.code === 'ESRCH' ? 'The process has already exited.' : 'Unable to stop this port process.'); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function terminateGroup(pid, signal) {
|
|
131
|
+
if (process.platform === 'win32') {
|
|
132
|
+
spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
try { process.kill(-pid, signal); }
|
|
136
|
+
catch { try { process.kill(pid, signal); } catch {} }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function groupExists(pid) {
|
|
140
|
+
if (process.platform === 'win32') return false;
|
|
141
|
+
try { process.kill(-pid, 0); return true; } catch { return false; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function retireGroup(pid) {
|
|
145
|
+
setTimeout(() => { if (groupExists(pid)) retireGroup(pid); else processGroups.delete(pid); }, 2000).unref();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function runScript(launcher, script) {
|
|
149
|
+
const key = keyFor(launcher.id, script.id);
|
|
150
|
+
if (running.has(key)) throw new Error('This script is already running.');
|
|
151
|
+
if (!existsSync(launcher.folder)) throw new Error('The configured project folder does not exist.');
|
|
152
|
+
logs.set(key, { output: `$ ${script.command}\n`, error: '' });
|
|
153
|
+
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
|
+
child.stdout.on('data', (chunk) => appendLog(key, 'output', chunk.toString()));
|
|
155
|
+
child.stderr.on('data', (chunk) => appendLog(key, 'error', chunk.toString()));
|
|
156
|
+
running.set(key, child);
|
|
157
|
+
processGroups.add(child.pid);
|
|
158
|
+
launchHistory.push({ key, groupPid: child.pid, launcher: launcher.alias, script: script.name, startedAt: new Date().toISOString(), ports: new Map() });
|
|
159
|
+
if (launchHistory.length > 200) launchHistory.splice(0, launchHistory.length - 200);
|
|
160
|
+
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); });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function stopScript(launcherId, scriptId) {
|
|
165
|
+
const key = keyFor(launcherId, scriptId);
|
|
166
|
+
const child = running.get(key);
|
|
167
|
+
if (!child) throw new Error('This script is not running.');
|
|
168
|
+
appendLog(key, 'output', '\nStop requested.\n');
|
|
169
|
+
stoppingGroups.add(child.pid);
|
|
170
|
+
terminateGroup(child.pid, 'SIGTERM');
|
|
171
|
+
setTimeout(() => { terminateGroup(child.pid, 'SIGKILL'); stoppingGroups.delete(child.pid); processGroups.delete(child.pid); }, 1500).unref();
|
|
172
|
+
running.delete(key);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function stopAllScripts(force = false) {
|
|
176
|
+
if (force) {
|
|
177
|
+
for (const pid of stoppingGroups) terminateGroup(pid, 'SIGKILL');
|
|
178
|
+
stoppingGroups.clear();
|
|
179
|
+
for (const pid of processGroups) terminateGroup(pid, 'SIGKILL');
|
|
180
|
+
processGroups.clear();
|
|
181
|
+
running.clear();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
for (const pid of processGroups) { stoppingGroups.add(pid); terminateGroup(pid, 'SIGTERM'); }
|
|
185
|
+
for (const [key] of running) appendLog(key, 'output', '\nWorkbench is shutting down. Stop requested.\n');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function waitForAllScripts(timeout = 3000) {
|
|
189
|
+
const deadline = Date.now() + timeout;
|
|
190
|
+
while (processGroups.size && Date.now() < deadline) {
|
|
191
|
+
for (const pid of processGroups) if (!groupExists(pid)) processGroups.delete(pid);
|
|
192
|
+
if (processGroups.size) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
193
|
+
}
|
|
194
|
+
return processGroups.size === 0;
|
|
195
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const parentPid = Number(process.env.BUDDY_PARENT_PID);
|
|
6
|
+
const command = process.env.BUDDY_SCRIPT_COMMAND;
|
|
7
|
+
const child = spawn(command, { cwd: process.cwd(), shell: true, stdio: 'inherit' });
|
|
8
|
+
|
|
9
|
+
function stopGroup() {
|
|
10
|
+
if (process.platform === 'win32') { child.kill('SIGTERM'); return; }
|
|
11
|
+
try { process.kill(-process.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const parentWatch = setInterval(() => {
|
|
15
|
+
try { process.kill(parentPid, 0); } catch { stopGroup(); }
|
|
16
|
+
}, 500);
|
|
17
|
+
|
|
18
|
+
child.on('exit', async (code) => {
|
|
19
|
+
if (process.platform === 'win32') process.exit(code || 0);
|
|
20
|
+
const groupWatch = setInterval(async () => {
|
|
21
|
+
try {
|
|
22
|
+
const { stdout } = await execFileAsync('pgrep', ['-g', String(process.pid)]);
|
|
23
|
+
const descendants = stdout.trim().split(/\s+/).filter((pid) => pid && Number(pid) !== process.pid);
|
|
24
|
+
if (descendants.length) return;
|
|
25
|
+
} catch {}
|
|
26
|
+
clearInterval(groupWatch); clearInterval(parentWatch); process.exit(code || 0);
|
|
27
|
+
}, 500);
|
|
28
|
+
});
|
package/server.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import express from 'express';
|
|
3
|
+
|
|
4
|
+
import { appendFileSync } from 'node:fs';
|
|
5
|
+
import { paths } from './server/config.js';
|
|
6
|
+
import { freePort } from './server/lib/free-port.js';
|
|
7
|
+
import launcherRoutes from './server/routes/launchers.js';
|
|
8
|
+
import pluginRoutes from './server/routes/plugins.js';
|
|
9
|
+
import { stopAllScripts, waitForAllScripts } from './server/services/process-manager.js';
|
|
10
|
+
import clipboardRoutes from './server/routes/clipboard.js';
|
|
11
|
+
import groupTaskRoutes from './server/routes/group-tasks.js';
|
|
12
|
+
import portDiagnosticsRoutes from './server/routes/port-diagnostics.js';
|
|
13
|
+
import settingsRoutes from './server/routes/settings.js';
|
|
14
|
+
import prReviewRoutes from './server/routes/pr-review.js';
|
|
15
|
+
import { startClipboardCapture } from './server/services/clipboard-history.js';
|
|
16
|
+
|
|
17
|
+
const port = Number(process.env.PORT || 3100);
|
|
18
|
+
const app = express();
|
|
19
|
+
app.use(express.json());
|
|
20
|
+
app.use(express.static(paths.ui));
|
|
21
|
+
app.use('/plugins', express.static(paths.plugins));
|
|
22
|
+
app.use('/api/launchers', launcherRoutes);
|
|
23
|
+
app.use('/api/plugins', pluginRoutes);
|
|
24
|
+
app.use('/api/clipboard', clipboardRoutes);
|
|
25
|
+
app.use('/api/group-tasks', groupTaskRoutes);
|
|
26
|
+
app.use('/api/port-diagnostics', portDiagnosticsRoutes);
|
|
27
|
+
app.use('/api/settings', settingsRoutes);
|
|
28
|
+
app.use('/api/pr-review', prReviewRoutes);
|
|
29
|
+
|
|
30
|
+
await freePort(port);
|
|
31
|
+
const server = app.listen(port, () => { console.log(`Buddy Workbench: http://localhost:${port} (pid ${process.pid})`); shutdownTrace(`server started (pid=${process.pid})`); });
|
|
32
|
+
startClipboardCapture();
|
|
33
|
+
|
|
34
|
+
let shuttingDown = false;
|
|
35
|
+
const shutdownTrace = (message) => { try { appendFileSync(paths.shutdownLog, `${new Date().toISOString()} ${message}\n`); } catch {} };
|
|
36
|
+
async function shutdown(exitCode = 0) {
|
|
37
|
+
if (shuttingDown) return;
|
|
38
|
+
shuttingDown = true;
|
|
39
|
+
shutdownTrace(`shutdown requested (pid=${process.pid}, exitCode=${exitCode})`);
|
|
40
|
+
console.log('Buddy Workbench: stopping launched scripts…');
|
|
41
|
+
stopAllScripts();
|
|
42
|
+
server.close();
|
|
43
|
+
const stopped = await waitForAllScripts(3000);
|
|
44
|
+
shutdownTrace(stopped ? 'all tracked process groups stopped' : 'forcing remaining process groups');
|
|
45
|
+
if (!stopped) console.log('Buddy Workbench: force stopping remaining scripts…');
|
|
46
|
+
stopAllScripts(true);
|
|
47
|
+
process.exit(exitCode);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
process.once('SIGINT', () => { shutdownTrace('received SIGINT'); void shutdown(); });
|
|
51
|
+
process.once('SIGTERM', () => { shutdownTrace('received SIGTERM'); void shutdown(); });
|
|
52
|
+
process.once('SIGHUP', () => { shutdownTrace('received SIGHUP'); void shutdown(); });
|
|
53
|
+
process.once('uncaughtException', (error) => { console.error(error); void shutdown(1); });
|
|
54
|
+
process.once('unhandledRejection', (error) => { console.error(error); void shutdown(1); });
|
|
55
|
+
process.once('exit', () => stopAllScripts(true));
|