bgrun 3.8.0 → 3.10.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.
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * POST /api/stop/:name — Stop a running process
3
+ *
4
+ * Kills the registered PID, then kills anything remaining on the port.
5
+ * Sets PID to 0 to prevent reconciliation from hijacking unrelated processes.
3
6
  */
4
- import { getProcess } from '../../../../../src/db';
5
- import { isProcessRunning, terminateProcess } from '../../../../../src/platform';
7
+ import { getProcess, updateProcessPid } from '../../../../../src/db';
8
+ import { isProcessRunning, terminateProcess, getProcessPorts, killProcessOnPort } from '../../../../../src/platform';
6
9
  import { measure } from 'measure-fn';
7
10
 
8
11
  export async function POST(req: Request, { params }: { params: { name: string } }) {
@@ -15,9 +18,24 @@ export async function POST(req: Request, { params }: { params: { name: string }
15
18
 
16
19
  const running = await isProcessRunning(proc.pid);
17
20
  if (!running) {
18
- return Response.json({ error: 'Process not running' }, { status: 404 });
21
+ // Already dead mark PID as 0 to prevent reconciliation
22
+ updateProcessPid(name, 0);
23
+ return Response.json({ success: true, already_stopped: true });
19
24
  }
20
25
 
26
+ // Detect ports BEFORE killing so we can clean them up
27
+ const ports = await getProcessPorts(proc.pid);
28
+
21
29
  await measure(`Stop "${name}" (PID ${proc.pid})`, () => terminateProcess(proc.pid));
30
+
31
+ // Also kill anything still on the ports
32
+ for (const port of ports) {
33
+ await killProcessOnPort(port);
34
+ }
35
+
36
+ // Mark PID as 0 — prevents reconcileProcessPids from re-attaching
37
+ // a random matching process as this one
38
+ updateProcessPid(name, 0);
39
+
22
40
  return Response.json({ success: true });
23
41
  }
@@ -989,6 +989,119 @@ tr.animate-in:nth-child(10) {
989
989
  border-color: rgba(20, 184, 166, 0.3);
990
990
  }
991
991
 
992
+ /* ─── Context Menu ─── */
993
+ .context-menu {
994
+ position: fixed;
995
+ z-index: 10000;
996
+ min-width: 180px;
997
+ background: rgba(22, 27, 34, 0.95);
998
+ backdrop-filter: blur(16px);
999
+ border: 1px solid var(--border);
1000
+ border-radius: 10px;
1001
+ padding: 4px;
1002
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.04);
1003
+ animation: contextIn 0.15s ease-out;
1004
+ }
1005
+
1006
+ .context-menu.closing {
1007
+ animation: contextOut 0.12s ease-in forwards;
1008
+ }
1009
+
1010
+ @keyframes contextIn {
1011
+ from {
1012
+ opacity: 0;
1013
+ transform: scale(0.95) translateY(-4px);
1014
+ }
1015
+
1016
+ to {
1017
+ opacity: 1;
1018
+ transform: scale(1) translateY(0);
1019
+ }
1020
+ }
1021
+
1022
+ @keyframes contextOut {
1023
+ from {
1024
+ opacity: 1;
1025
+ transform: scale(1) translateY(0);
1026
+ }
1027
+
1028
+ to {
1029
+ opacity: 0;
1030
+ transform: scale(0.95) translateY(-4px);
1031
+ }
1032
+ }
1033
+
1034
+ .context-item {
1035
+ display: flex;
1036
+ align-items: center;
1037
+ gap: 10px;
1038
+ width: 100%;
1039
+ padding: 8px 12px;
1040
+ background: none;
1041
+ border: none;
1042
+ border-radius: 6px;
1043
+ color: var(--text-secondary);
1044
+ font-size: 0.8125rem;
1045
+ cursor: pointer;
1046
+ transition: all 0.12s ease;
1047
+ text-align: left;
1048
+ font-family: inherit;
1049
+ }
1050
+
1051
+ .context-item svg {
1052
+ width: 15px;
1053
+ height: 15px;
1054
+ flex-shrink: 0;
1055
+ opacity: 0.7;
1056
+ }
1057
+
1058
+ .context-item:hover {
1059
+ background: rgba(255, 255, 255, 0.06);
1060
+ color: var(--text-primary);
1061
+ }
1062
+
1063
+ .context-item:hover svg {
1064
+ opacity: 1;
1065
+ }
1066
+
1067
+ .context-item.danger:hover {
1068
+ background: var(--danger-bg);
1069
+ color: var(--danger);
1070
+ }
1071
+
1072
+ .context-item.danger:hover svg {
1073
+ color: var(--danger);
1074
+ }
1075
+
1076
+ .context-item.success:hover {
1077
+ background: var(--success-bg);
1078
+ color: var(--success);
1079
+ }
1080
+
1081
+ .context-item.success:hover svg {
1082
+ color: var(--success);
1083
+ }
1084
+
1085
+ .context-item.deploy:hover {
1086
+ background: rgba(20, 184, 166, 0.1);
1087
+ color: #14b8a6;
1088
+ }
1089
+
1090
+ .context-item.deploy:hover svg {
1091
+ color: #14b8a6;
1092
+ }
1093
+
1094
+ .context-divider {
1095
+ height: 1px;
1096
+ background: var(--border);
1097
+ margin: 4px 8px;
1098
+ }
1099
+
1100
+ /* Hint that rows are right-clickable */
1101
+ #processes-table tr[data-process-name] {
1102
+ cursor: default;
1103
+ }
1104
+
992
1105
  /* ─── Empty State ─── */
993
1106
  .empty-state {
994
1107
  text-align: center;
@@ -176,28 +176,6 @@ function ProcessRow({ p, animate }: { p: ProcessData; animate?: boolean }) {
176
176
  </td>
177
177
  <td className="command" title={p.command}>{p.command}</td>
178
178
  <td className="runtime">{formatRuntime(p.runtime)}</td>
179
- <td className="actions">
180
- <button className="action-btn info" data-action="logs" data-name={p.name} title="View Logs">
181
- <LogsIcon />
182
- </button>
183
- {p.running
184
- ? <button className="action-btn danger" data-action="stop" data-name={p.name} title="Stop">
185
- <StopIcon />
186
- </button>
187
- : <button className="action-btn success" data-action="restart" data-name={p.name} title="Start">
188
- <PlayIcon />
189
- </button>
190
- }
191
- <button className="action-btn warning" data-action="restart" data-name={p.name} title="Restart">
192
- <RestartIcon />
193
- </button>
194
- <button className="action-btn deploy" data-action="deploy" data-name={p.name} title="Deploy (git pull + restart)">
195
- <DeployIcon />
196
- </button>
197
- <button className="action-btn danger" data-action="delete" data-name={p.name} title="Delete">
198
- <TrashIcon />
199
- </button>
200
- </td>
201
179
  </tr>
202
180
  );
203
181
  }
@@ -638,20 +616,99 @@ export default function mount(): () => void {
638
616
 
639
617
  const tbody = $('processes-table');
640
618
 
641
- // Row click open drawer
642
- tbody?.addEventListener('click', (e: Event) => {
643
- const btn = (e.target as Element).closest('[data-action]');
644
- if (btn) {
645
- handleAction(e);
646
- return;
619
+ // ─── Context Menu ───
620
+ let contextMenuEl: HTMLElement | null = null;
621
+
622
+ function closeContextMenu() {
623
+ if (contextMenuEl) {
624
+ contextMenuEl.classList.add('closing');
625
+ setTimeout(() => { contextMenuEl?.remove(); contextMenuEl = null; }, 150);
647
626
  }
627
+ }
628
+
629
+ function showContextMenu(name: string, x: number, y: number) {
630
+ closeContextMenu();
631
+ const proc = allProcesses.find(p => p.name === name);
632
+ if (!proc) return;
633
+
634
+ const menu = (
635
+ <div className="context-menu" style={{ left: `${x}px`, top: `${y}px` }}>
636
+ <button className="context-item" data-action="logs" data-name={name}>
637
+ <LogsIcon /> View Logs
638
+ </button>
639
+ <div className="context-divider"></div>
640
+ {proc.running
641
+ ? <button className="context-item danger" data-action="stop" data-name={name}>
642
+ <StopIcon /> Stop
643
+ </button>
644
+ : <button className="context-item success" data-action="restart" data-name={name}>
645
+ <PlayIcon /> Start
646
+ </button>
647
+ }
648
+ <button className="context-item" data-action="restart" data-name={name}>
649
+ <RestartIcon /> Restart
650
+ </button>
651
+ <button className="context-item deploy" data-action="deploy" data-name={name}>
652
+ <DeployIcon /> Deploy
653
+ </button>
654
+ <div className="context-divider"></div>
655
+ <button className="context-item danger" data-action="delete" data-name={name}>
656
+ <TrashIcon /> Delete
657
+ </button>
658
+ </div>
659
+ ) as unknown as HTMLElement;
660
+
661
+ // Handle clicks inside the menu
662
+ menu.addEventListener('click', (e: Event) => {
663
+ const item = (e.target as Element).closest('[data-action]');
664
+ if (item) {
665
+ handleAction(e);
666
+ closeContextMenu();
667
+ }
668
+ });
669
+
670
+ document.body.appendChild(menu);
671
+ contextMenuEl = menu;
672
+
673
+ // Adjust position if menu goes off-screen
674
+ const rect = menu.getBoundingClientRect();
675
+ if (rect.right > window.innerWidth) menu.style.left = `${x - rect.width}px`;
676
+ if (rect.bottom > window.innerHeight) menu.style.top = `${y - rect.height}px`;
677
+ }
678
+
679
+ // Right-click on table rows → context menu
680
+ tbody?.addEventListener('contextmenu', (e: Event) => {
681
+ const me = e as MouseEvent;
682
+ const row = (me.target as Element).closest('tr[data-process-name]') as HTMLElement;
683
+ if (row && row.dataset.processName) {
684
+ me.preventDefault();
685
+ showContextMenu(row.dataset.processName, me.clientX, me.clientY);
686
+ }
687
+ });
688
+
689
+ // Close context menu on click outside or Escape
690
+ document.addEventListener('click', (e: Event) => {
691
+ if (contextMenuEl && !contextMenuEl.contains(e.target as Node)) {
692
+ closeContextMenu();
693
+ }
694
+ });
695
+ document.addEventListener('contextmenu', (e: Event) => {
696
+ // Allow right-click to close existing menu when clicking elsewhere
697
+ if (contextMenuEl && !contextMenuEl.contains(e.target as Node)) {
698
+ const row = (e.target as Element).closest('tr[data-process-name]');
699
+ if (!row) closeContextMenu();
700
+ }
701
+ });
702
+
703
+ // Row click → open drawer (left click)
704
+ tbody?.addEventListener('click', (e: Event) => {
648
705
  const row = (e.target as Element).closest('tr[data-process-name]') as HTMLElement;
649
706
  if (row && row.dataset.processName) {
650
707
  openDrawer(row.dataset.processName);
651
708
  }
652
709
  });
653
710
 
654
- // Mobile cards click → same delegation
711
+ // Mobile cards click → keep inline buttons
655
712
  const mobileCards = $('mobile-cards');
656
713
  mobileCards?.addEventListener('click', (e: Event) => {
657
714
  const btn = (e.target as Element).closest('[data-action]');
@@ -665,6 +722,22 @@ export default function mount(): () => void {
665
722
  }
666
723
  });
667
724
 
725
+ // Mobile cards → context menu on long-press
726
+ let longPressTimer: ReturnType<typeof setTimeout> | null = null;
727
+ mobileCards?.addEventListener('touchstart', (e: Event) => {
728
+ const te = e as TouchEvent;
729
+ const card = (te.target as Element).closest('.process-card[data-process-name]') as HTMLElement;
730
+ if (card && card.dataset.processName) {
731
+ const name = card.dataset.processName;
732
+ const touch = te.touches[0];
733
+ longPressTimer = setTimeout(() => {
734
+ showContextMenu(name, touch.clientX, touch.clientY);
735
+ }, 500);
736
+ }
737
+ });
738
+ mobileCards?.addEventListener('touchend', () => { if (longPressTimer) clearTimeout(longPressTimer); });
739
+ mobileCards?.addEventListener('touchmove', () => { if (longPressTimer) clearTimeout(longPressTimer); });
740
+
668
741
  // ─── Detail Drawer ───
669
742
 
670
743
  const drawer = $('detail-drawer');
@@ -1196,6 +1269,10 @@ export default function mount(): () => void {
1196
1269
  return;
1197
1270
  }
1198
1271
  if (e.key === 'Escape') {
1272
+ if (contextMenuEl) {
1273
+ closeContextMenu();
1274
+ return;
1275
+ }
1199
1276
  if (drawer?.classList.contains('open')) {
1200
1277
  closeDrawer();
1201
1278
  } else {
@@ -1255,6 +1332,7 @@ export default function mount(): () => void {
1255
1332
  $('modal-create-btn')?.removeEventListener('click', createProcess);
1256
1333
  $('refresh-btn')?.removeEventListener('click', loadProcesses);
1257
1334
  document.removeEventListener('keydown', handleKeydown);
1335
+ closeContextMenu();
1258
1336
  if (eventSource) eventSource.close();
1259
1337
  if (logRefreshTimer) clearInterval(logRefreshTimer);
1260
1338
  if (sseThrottleTimer) clearTimeout(sseThrottleTimer);
@@ -77,7 +77,6 @@ export default function DashboardPage() {
77
77
  <th style={{ width: '70px' }}>Memory</th>
78
78
  <th>Command</th>
79
79
  <th style={{ width: '100px' }}>Runtime</th>
80
- <th style={{ width: '150px' }}>Actions</th>
81
80
  </tr>
82
81
  </thead>
83
82
  <tbody id="processes-table">
package/dist/index.js CHANGED
@@ -25,6 +25,8 @@ function getHomeDir() {
25
25
  return os.homedir();
26
26
  }
27
27
  async function isProcessRunning(pid, command) {
28
+ if (pid <= 0)
29
+ return false;
28
30
  return plat.measure(`PID ${pid} alive?`, async () => {
29
31
  try {
30
32
  if (command && (command.includes("docker run") || command.includes("docker-compose up") || command.includes("docker compose up"))) {
@@ -1041,6 +1043,7 @@ async function handleStop(name) {
1041
1043
  for (const port of ports) {
1042
1044
  await killProcessOnPort(port);
1043
1045
  }
1046
+ updateProcessPid(name, 0);
1044
1047
  announce(`Process '${name}' has been stopped (kept in registry).`, "Process Stopped");
1045
1048
  }
1046
1049
  async function handleDeleteAll() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bgrun",
3
- "version": "3.8.0",
3
+ "version": "3.10.0",
4
4
  "description": "bgrun — A lightweight process manager for Bun",
5
5
  "type": "module",
6
6
  "main": "./src/api.ts",
@@ -1,5 +1,5 @@
1
1
 
2
- import { getProcess, removeProcessByName, removeProcess, getAllProcesses, removeAllProcesses } from "../db";
2
+ import { getProcess, removeProcessByName, removeProcess, getAllProcesses, removeAllProcesses, updateProcessPid } from "../db";
3
3
  import { isProcessRunning, terminateProcess, getProcessPorts, killProcessOnPort, waitForPortFree } from "../platform";
4
4
  import { announce, error } from "../logger";
5
5
  import * as fs from "fs";
@@ -82,6 +82,10 @@ export async function handleStop(name: string) {
82
82
  await killProcessOnPort(port);
83
83
  }
84
84
 
85
+ // Mark PID as 0 — prevents reconcileProcessPids from re-attaching
86
+ // a random matching process as this one
87
+ updateProcessPid(name, 0);
88
+
85
89
  announce(`Process '${name}' has been stopped (kept in registry).`, "Process Stopped");
86
90
  }
87
91
 
package/src/platform.ts CHANGED
@@ -28,6 +28,9 @@ export function getHomeDir(): string {
28
28
  * For Docker containers, checks container status instead of PID
29
29
  */
30
30
  export async function isProcessRunning(pid: number, command?: string): Promise<boolean> {
31
+ // PID 0 means intentionally stopped — never alive
32
+ if (pid <= 0) return false;
33
+
31
34
  return plat.measure(`PID ${pid} alive?`, async () => {
32
35
  try {
33
36
  // Docker container detection
@@ -318,7 +321,9 @@ export async function reconcileProcessPids(
318
321
  ): Promise<Map<string, number>> {
319
322
  return await plat.measure('Reconcile PIDs', async () => {
320
323
  const result = new Map<string, number>();
321
- const needsReconciliation = processes.filter(p => deadPids.has(p.pid));
324
+ // Skip processes with PID=0 these were intentionally stopped
325
+ // and should NOT be reconciled to avoid hijacking unrelated processes
326
+ const needsReconciliation = processes.filter(p => deadPids.has(p.pid) && p.pid > 0);
322
327
  if (needsReconciliation.length === 0) return result;
323
328
 
324
329
  try {