bm2 1.0.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.
@@ -0,0 +1,285 @@
1
+ /**
2
+ * BM2 — Bun Process Manager
3
+ * A production-grade process manager for Bun.
4
+ *
5
+ * Features:
6
+ * - Fork & cluster execution modes
7
+ * - Auto-restart & crash recovery
8
+ * - Health checks & monitoring
9
+ * - Log management & rotation
10
+ * - Deployment support
11
+ *
12
+ * https://github.com/your-org/bm2
13
+ * License: GPL-3.0-only
14
+ * Author: Zak <zak@maxxpainn.com>
15
+ */
16
+ export function getDashboardHTML(): string {
17
+ return `<!DOCTYPE html>
18
+ <html lang="en">
19
+ <head>
20
+ <meta charset="UTF-8">
21
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
22
+ <title>BM2 Dashboard</title>
23
+ <style>
24
+ * { margin: 0; padding: 0; box-sizing: border-box; }
25
+ :root {
26
+ --bg: #0d1117; --surface: #161b22; --border: #30363d;
27
+ --text: #c9d1d9; --text-dim: #8b949e; --accent: #58a6ff;
28
+ --green: #3fb950; --red: #f85149; --yellow: #d29922;
29
+ --orange: #db6d28; --purple: #bc8cff;
30
+ }
31
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace; background: var(--bg); color: var(--text); }
32
+ .header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; }
33
+ .header h1 { font-size: 20px; color: var(--accent); }
34
+ .header .meta { color: var(--text-dim); font-size: 13px; }
35
+ .container { max-width: 1400px; margin: 0 auto; padding: 20px; }
36
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; margin-bottom: 24px; }
37
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
38
+ .card h3 { font-size: 13px; color: var(--text-dim); text-transform: uppercase; margin-bottom: 12px; letter-spacing: 0.5px; }
39
+ .stat { font-size: 28px; font-weight: 700; }
40
+ .stat.green { color: var(--green); }
41
+ .stat.red { color: var(--red); }
42
+ .stat.yellow { color: var(--yellow); }
43
+ table { width: 100%; border-collapse: collapse; }
44
+ th { text-align: left; padding: 10px 12px; color: var(--text-dim); font-size: 12px; text-transform: uppercase; border-bottom: 2px solid var(--border); letter-spacing: 0.5px; }
45
+ td { padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 14px; font-family: monospace; }
46
+ tr:hover td { background: rgba(88, 166, 255, 0.05); }
47
+ .status { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 12px; font-weight: 600; }
48
+ .status.online { background: rgba(63, 185, 80, 0.15); color: var(--green); }
49
+ .status.stopped { background: rgba(139, 148, 158, 0.15); color: var(--text-dim); }
50
+ .status.errored { background: rgba(248, 81, 73, 0.15); color: var(--red); }
51
+ .status.launching, .status.waiting-restart { background: rgba(210, 153, 34, 0.15); color: var(--yellow); }
52
+ .btn { padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); cursor: pointer; font-size: 12px; transition: all 0.2s; }
53
+ .btn:hover { border-color: var(--accent); color: var(--accent); }
54
+ .btn.danger:hover { border-color: var(--red); color: var(--red); }
55
+ .btn.success:hover { border-color: var(--green); color: var(--green); }
56
+ .actions { display: flex; gap: 6px; }
57
+ .chart-container { height: 200px; position: relative; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 24px; }
58
+ .chart-title { font-size: 13px; color: var(--text-dim); text-transform: uppercase; margin-bottom: 8px; }
59
+ canvas { width: 100% !important; height: 160px !important; }
60
+ .logs-panel { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 24px; }
61
+ .logs-panel h3 { font-size: 13px; color: var(--text-dim); text-transform: uppercase; margin-bottom: 12px; }
62
+ .log-output { background: #000; border-radius: 4px; padding: 12px; font-family: monospace; font-size: 12px; max-height: 400px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; color: var(--text); }
63
+ .log-output .err { color: var(--red); }
64
+ .log-output .timestamp { color: var(--text-dim); }
65
+ .tabs { display: flex; gap: 0; margin-bottom: 16px; }
66
+ .tab { padding: 8px 16px; background: var(--surface); border: 1px solid var(--border); cursor: pointer; font-size: 13px; color: var(--text-dim); transition: all 0.2s; }
67
+ .tab:first-child { border-radius: 6px 0 0 6px; }
68
+ .tab:last-child { border-radius: 0 6px 6px 0; }
69
+ .tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
70
+ .system-info { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
71
+ .sys-stat { text-align: center; }
72
+ .sys-stat .label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; }
73
+ .sys-stat .value { font-size: 18px; font-weight: 600; margin-top: 4px; }
74
+ .progress-bar { height: 6px; background: var(--border); border-radius: 3px; margin-top: 6px; overflow: hidden; }
75
+ .progress-bar .fill { height: 100%; border-radius: 3px; transition: width 0.5s; }
76
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
77
+ .live-indicator { display: inline-block; width: 8px; height: 8px; background: var(--green); border-radius: 50%; margin-right: 8px; animation: pulse 2s infinite; }
78
+ </style>
79
+ </head>
80
+ <body>
81
+ <div class="header">
82
+ <h1>⚡ BM2 Dashboard</h1>
83
+ <div class="meta"><span class="live-indicator"></span>Live • <span id="update-time">-</span></div>
84
+ </div>
85
+ <div class="container">
86
+ <div class="grid" id="stats-grid"></div>
87
+ <div class="chart-container">
88
+ <div class="chart-title">CPU & Memory Over Time</div>
89
+ <canvas id="chart"></canvas>
90
+ </div>
91
+ <div class="card" style="margin-bottom: 24px;">
92
+ <h3>System</h3>
93
+ <div class="system-info" id="system-info"></div>
94
+ </div>
95
+ <div class="card" style="margin-bottom: 24px;">
96
+ <h3>Processes</h3>
97
+ <table>
98
+ <thead>
99
+ <tr>
100
+ <th>ID</th><th>Name</th><th>Status</th><th>PID</th>
101
+ <th>CPU</th><th>Memory</th><th>Restarts</th><th>Uptime</th>
102
+ <th>Actions</th>
103
+ </tr>
104
+ </thead>
105
+ <tbody id="process-table"></tbody>
106
+ </table>
107
+ </div>
108
+ <div class="logs-panel">
109
+ <h3>Logs</h3>
110
+ <div class="tabs" id="log-tabs"></div>
111
+ <div class="log-output" id="log-output">Select a process to view logs</div>
112
+ </div>
113
+ </div>
114
+ <script>
115
+ const WS_URL = location.origin.replace('http','ws') + '/ws';
116
+ let ws;
117
+ let chartData = { labels: [], cpu: [], memory: [] };
118
+ let selectedLogProcess = null;
119
+
120
+ function formatBytes(b) {
121
+ if (!b) return '0 B';
122
+ const u = ['B','KB','MB','GB'];
123
+ const i = Math.floor(Math.log(b)/Math.log(1024));
124
+ return (b/Math.pow(1024,i)).toFixed(1)+' '+u[i];
125
+ }
126
+ function formatUptime(ms) {
127
+ const s = Math.floor(ms/1000), m = Math.floor(s/60), h = Math.floor(m/60), d = Math.floor(h/24);
128
+ if (d>0) return d+'d '+h%24+'h';
129
+ if (h>0) return h+'h '+m%60+'m';
130
+ if (m>0) return m+'m '+s%60+'s';
131
+ return s+'s';
132
+ }
133
+
134
+ function connect() {
135
+ ws = new WebSocket(WS_URL);
136
+ ws.onmessage = (e) => {
137
+ const data = JSON.parse(e.data);
138
+ if (data.type === 'state') render(data.data);
139
+ if (data.type === 'logs') renderLogs(data.data);
140
+ };
141
+ ws.onclose = () => setTimeout(connect, 2000);
142
+ ws.onerror = () => ws.close();
143
+ }
144
+
145
+ function send(type, data) { ws?.send(JSON.stringify({type,data})); }
146
+
147
+ function render(state) {
148
+ const { processes, metrics } = state;
149
+ document.getElementById('update-time').textContent = new Date().toLocaleTimeString();
150
+
151
+ // Stats cards
152
+ const online = processes.filter(p => p.status==='online').length;
153
+ const errored = processes.filter(p => p.status==='errored').length;
154
+ const totalMem = processes.reduce((s,p) => s+p.monit.memory, 0);
155
+ const totalCpu = processes.reduce((s,p) => s+p.monit.cpu, 0);
156
+
157
+ document.getElementById('stats-grid').innerHTML = \`
158
+ <div class="card"><h3>Online</h3><div class="stat green">\${online}</div></div>
159
+ <div class="card"><h3>Errored</h3><div class="stat red">\${errored}</div></div>
160
+ <div class="card"><h3>Total CPU</h3><div class="stat">\${totalCpu.toFixed(1)}%</div></div>
161
+ <div class="card"><h3>Total Memory</h3><div class="stat">\${formatBytes(totalMem)}</div></div>
162
+ \`;
163
+
164
+ // System info
165
+ if (metrics?.system) {
166
+ const sys = metrics.system;
167
+ const memPct = ((sys.totalMemory - sys.freeMemory) / sys.totalMemory * 100).toFixed(1);
168
+ document.getElementById('system-info').innerHTML = \`
169
+ <div class="sys-stat"><div class="label">Platform</div><div class="value">\${sys.platform}</div></div>
170
+ <div class="sys-stat"><div class="label">CPUs</div><div class="value">\${sys.cpuCount}</div></div>
171
+ <div class="sys-stat"><div class="label">Load (1m)</div><div class="value">\${sys.loadAvg[0].toFixed(2)}</div></div>
172
+ <div class="sys-stat">
173
+ <div class="label">Memory</div><div class="value">\${memPct}%</div>
174
+ <div class="progress-bar"><div class="fill" style="width:\${memPct}%;background:\${memPct>80?'var(--red)':memPct>60?'var(--yellow)':'var(--green)'}"></div></div>
175
+ </div>
176
+ \`;
177
+ }
178
+
179
+ // Chart data
180
+ const now = new Date().toLocaleTimeString();
181
+ chartData.labels.push(now);
182
+ chartData.cpu.push(totalCpu);
183
+ chartData.memory.push(totalMem / 1024 / 1024);
184
+ if (chartData.labels.length > 60) {
185
+ chartData.labels.shift(); chartData.cpu.shift(); chartData.memory.shift();
186
+ }
187
+ drawChart();
188
+
189
+ // Process table
190
+ document.getElementById('process-table').innerHTML = processes.map(p => \`
191
+ <tr>
192
+ <td>\${p.pm_id}</td>
193
+ <td>\${p.name}</td>
194
+ <td><span class="status \${p.status}">\${p.status}</span></td>
195
+ <td>\${p.pid||'-'}</td>
196
+ <td>\${p.monit.cpu.toFixed(1)}%</td>
197
+ <td>\${formatBytes(p.monit.memory)}</td>
198
+ <td>\${p.pm2_env.restart_time}</td>
199
+ <td>\${p.status==='online' ? formatUptime(Date.now()-p.pm2_env.pm_uptime) : '-'}</td>
200
+ <td class="actions">
201
+ <button class="btn success" onclick="send('restart',{target:'\${p.pm_id}'})">↻</button>
202
+ <button class="btn danger" onclick="send('stop',{target:'\${p.pm_id}'})">■</button>
203
+ <button class="btn" onclick="viewLogs(\${p.pm_id},'\${p.name}')">📋</button>
204
+ </td>
205
+ </tr>
206
+ \`).join('');
207
+
208
+ // Log tabs
209
+ document.getElementById('log-tabs').innerHTML = processes.map(p => \`
210
+ <div class="tab \${selectedLogProcess===p.pm_id?'active':''}" onclick="viewLogs(\${p.pm_id},'\${p.name}')">\${p.name}</div>
211
+ \`).join('');
212
+ }
213
+
214
+ function viewLogs(id, name) {
215
+ selectedLogProcess = id;
216
+ send('getLogs', { target: id, lines: 50 });
217
+ }
218
+
219
+ function renderLogs(logs) {
220
+ if (!logs || !logs.length) return;
221
+ const el = document.getElementById('log-output');
222
+ let html = '';
223
+ for (const log of logs) {
224
+ if (log.out) html += log.out.split('\\n').map(l => {
225
+ const m = l.match(/^\\[([^\\]]+)\\]/);
226
+ return m ? '<span class="timestamp">['+m[1]+']</span>'+l.slice(m[0].length) : l;
227
+ }).join('\\n');
228
+ if (log.err) html += log.err.split('\\n').map(l => '<span class="err">'+l+'</span>').join('\\n');
229
+ }
230
+ el.innerHTML = html || 'No logs available';
231
+ el.scrollTop = el.scrollHeight;
232
+ }
233
+
234
+ function drawChart() {
235
+ const canvas = document.getElementById('chart');
236
+ const ctx = canvas.getContext('2d');
237
+ const w = canvas.offsetWidth, h = 160;
238
+ canvas.width = w * 2; canvas.height = h * 2;
239
+ ctx.scale(2, 2);
240
+ ctx.clearRect(0, 0, w, h);
241
+
242
+ const len = chartData.labels.length;
243
+ if (len < 2) return;
244
+
245
+ const maxCpu = Math.max(...chartData.cpu, 1);
246
+ const maxMem = Math.max(...chartData.memory, 1);
247
+ const stepX = w / (len - 1);
248
+
249
+ // Grid
250
+ ctx.strokeStyle = '#30363d'; ctx.lineWidth = 0.5;
251
+ for (let i = 0; i < 4; i++) {
252
+ const y = h * i / 4;
253
+ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
254
+ }
255
+
256
+ // CPU line
257
+ ctx.strokeStyle = '#58a6ff'; ctx.lineWidth = 2;
258
+ ctx.beginPath();
259
+ chartData.cpu.forEach((v, i) => {
260
+ const x = i * stepX, y = h - (v / maxCpu) * (h - 20);
261
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
262
+ });
263
+ ctx.stroke();
264
+
265
+ // Memory line
266
+ ctx.strokeStyle = '#3fb950'; ctx.lineWidth = 2;
267
+ ctx.beginPath();
268
+ chartData.memory.forEach((v, i) => {
269
+ const x = i * stepX, y = h - (v / maxMem) * (h - 20);
270
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
271
+ });
272
+ ctx.stroke();
273
+
274
+ // Legend
275
+ ctx.font = '11px monospace';
276
+ ctx.fillStyle = '#58a6ff'; ctx.fillText('● CPU ' + chartData.cpu[len-1]?.toFixed(1) + '%', 10, 14);
277
+ ctx.fillStyle = '#3fb950'; ctx.fillText('● MEM ' + chartData.memory[len-1]?.toFixed(1) + 'MB', 120, 14);
278
+ }
279
+
280
+ connect();
281
+ setInterval(() => { if (ws?.readyState === 1) send('getState', {}); }, 2000);
282
+ </script>
283
+ </body>
284
+ </html>`;
285
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * BM2 — Bun Process Manager
3
+ * A production-grade process manager for Bun.
4
+ *
5
+ * Features:
6
+ * - Fork & cluster execution modes
7
+ * - Auto-restart & crash recovery
8
+ * - Health checks & monitoring
9
+ * - Log management & rotation
10
+ * - Deployment support
11
+ *
12
+ * https://github.com/your-org/bm2
13
+ * License: GPL-3.0-only
14
+ * Author: Zak <zak@maxxpainn.com>
15
+ */
16
+
17
+ import { ProcessManager } from "./process-manager";
18
+ import { getDashboardHTML } from "./dashboard-ui";
19
+ import { DASHBOARD_PORT, METRICS_PORT } from "./constants";
20
+ import type { Server, ServerWebSocket } from "bun";
21
+
22
+ export class Dashboard {
23
+
24
+ private server: Server<unknown> | null = null;
25
+ private metricsServer: Server<unknown> | null = null;
26
+
27
+ private clients: Set<ServerWebSocket<unknown>> = new Set();
28
+ private pm: ProcessManager;
29
+ private updateInterval: ReturnType<typeof setInterval> | null = null;
30
+
31
+ constructor(pm: ProcessManager) {
32
+ this.pm = pm;
33
+ }
34
+
35
+ start(port: number = DASHBOARD_PORT, metricsPort: number = METRICS_PORT) {
36
+ // Dashboard + WebSocket server
37
+ this.server = Bun.serve<unknown>({
38
+ port,
39
+ fetch: (req, server) => {
40
+ const url = new URL(req.url);
41
+
42
+ if (url.pathname === "/ws") {
43
+ if (server.upgrade(req, { data: undefined })) return;
44
+ return new Response("WebSocket upgrade failed", { status: 400 });
45
+ }
46
+
47
+ if (url.pathname === "/api/processes") {
48
+ return Response.json(this.pm.list());
49
+ }
50
+
51
+ if (url.pathname === "/api/metrics") {
52
+ const metrics = this.pm.monitor.getLatest();
53
+ return Response.json(metrics);
54
+ }
55
+
56
+ if (url.pathname === "/api/metrics/history") {
57
+ const seconds = parseInt(url.searchParams.get("seconds") || "300");
58
+ return Response.json(this.pm.getMetricsHistory(seconds));
59
+ }
60
+
61
+ if (url.pathname === "/api/prometheus" || url.pathname === "/metrics") {
62
+ return new Response(this.pm.getPrometheusMetrics(), {
63
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
64
+ });
65
+ }
66
+
67
+ // Action endpoints
68
+ if (req.method === "POST") {
69
+ return this.handleAction(url.pathname, req);
70
+ }
71
+
72
+ // Serve dashboard HTML
73
+ return new Response(getDashboardHTML(), {
74
+ headers: { "Content-Type": "text/html" },
75
+ });
76
+ },
77
+ websocket: {
78
+ open: (ws) => {
79
+ this.clients.add(ws);
80
+ // Send initial state
81
+ const state = {
82
+ processes: this.pm.list(),
83
+ metrics: this.pm.monitor.getLatest(),
84
+ };
85
+ ws.send(JSON.stringify({ type: "state", data: state }));
86
+ },
87
+ message: async (ws, message) => {
88
+ try {
89
+ const msg = JSON.parse(String(message));
90
+ await this.handleWsMessage(ws, msg);
91
+ } catch {}
92
+ },
93
+ close: (ws) => {
94
+ this.clients.delete(ws);
95
+ },
96
+ },
97
+ });
98
+
99
+ // Separate Prometheus metrics server
100
+ this.metricsServer = Bun.serve({
101
+ port: metricsPort,
102
+ fetch: (req) => {
103
+ const url = new URL(req.url);
104
+ if (url.pathname === "/metrics") {
105
+ return new Response(this.pm.getPrometheusMetrics(), {
106
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
107
+ });
108
+ }
109
+ return new Response("BM2 Metrics Server\nGET /metrics for Prometheus format", {
110
+ status: 200,
111
+ });
112
+ },
113
+ });
114
+
115
+ // Periodic broadcast
116
+ this.updateInterval = setInterval(async () => {
117
+ await this.pm.getMetrics(); // Collect snapshot
118
+ this.broadcast();
119
+ }, 2000);
120
+
121
+ console.log(`[bm2] Dashboard running at http://localhost:${port}`);
122
+ console.log(`[bm2] Prometheus metrics at http://localhost:${metricsPort}/metrics`);
123
+ }
124
+
125
+ private async handleAction(pathname: string, req: Request): Promise<Response> {
126
+ try {
127
+
128
+ const body = (await req.json()) as { target?: string; count?: number };
129
+
130
+ switch (pathname) {
131
+ case "/api/restart":
132
+ return Response.json(await this.pm.restart(body.target || "all"));
133
+ case "/api/stop":
134
+ return Response.json(await this.pm.stop(body.target || "all"));
135
+ case "/api/reload":
136
+ return Response.json(await this.pm.reload(body.target || "all"));
137
+ case "/api/delete":
138
+ return Response.json(await this.pm.del(body.target!));
139
+ case "/api/scale":
140
+ return Response.json(await this.pm.scale(body.target!, body.count!));
141
+ case "/api/flush":
142
+ await this.pm.flushLogs(body.target);
143
+ return Response.json({ success: true });
144
+ default:
145
+ return new Response("Not Found", { status: 404 });
146
+ }
147
+ } catch (err: any) {
148
+ return Response.json({ error: err.message }, { status: 500 });
149
+ }
150
+ }
151
+
152
+ private async handleWsMessage(ws: ServerWebSocket<unknown>, msg: any) {
153
+ switch (msg.type) {
154
+ case "getState": {
155
+ const state = {
156
+ processes: this.pm.list(),
157
+ metrics: this.pm.monitor.getLatest(),
158
+ };
159
+ ws.send(JSON.stringify({ type: "state", data: state }));
160
+ break;
161
+ }
162
+ case "getLogs": {
163
+ const logs = await this.pm.getLogs(msg.data.target, msg.data.lines || 50);
164
+ ws.send(JSON.stringify({ type: "logs", data: logs }));
165
+ break;
166
+ }
167
+ case "restart":
168
+ await this.pm.restart(msg.data.target);
169
+ break;
170
+ case "stop":
171
+ await this.pm.stop(msg.data.target);
172
+ break;
173
+ case "reload":
174
+ await this.pm.reload(msg.data.target);
175
+ break;
176
+ case "scale":
177
+ await this.pm.scale(msg.data.target, msg.data.count);
178
+ break;
179
+ }
180
+ }
181
+
182
+ private broadcast() {
183
+ const state = {
184
+ processes: this.pm.list(),
185
+ metrics: this.pm.monitor.getLatest(),
186
+ };
187
+ const message = JSON.stringify({ type: "state", data: state });
188
+ for (const client of this.clients) {
189
+ try {
190
+ client.send(message);
191
+ } catch {
192
+ this.clients.delete(client);
193
+ }
194
+ }
195
+ }
196
+
197
+ stop() {
198
+ if (this.updateInterval) clearInterval(this.updateInterval);
199
+ if (this.server) this.server.stop();
200
+ if (this.metricsServer) this.metricsServer.stop();
201
+ this.clients.clear();
202
+ }
203
+ }
package/src/deploy.ts ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * BM2 — Bun Process Manager
3
+ * A production-grade process manager for Bun.
4
+ *
5
+ * Features:
6
+ * - Fork & cluster execution modes
7
+ * - Auto-restart & crash recovery
8
+ * - Health checks & monitoring
9
+ * - Log management & rotation
10
+ * - Deployment support
11
+ *
12
+ * https://github.com/your-org/bm2
13
+ * License: GPL-3.0-only
14
+ * Author: Zak <zak@maxxpainn.com>
15
+ */
16
+ import type { DeployConfig } from "./types";
17
+
18
+ export class DeployManager {
19
+ async deploy(config: DeployConfig, command?: string): Promise<void> {
20
+ const hosts = Array.isArray(config.host) ? config.host : [config.host];
21
+ const sshOpts = config.ssh_options || "";
22
+
23
+ for (const host of hosts) {
24
+ const target = `${config.user}@${host}`;
25
+ console.log(`\n[bm2] Deploying to ${target}...`);
26
+
27
+ const remotePath = config.path;
28
+ const currentPath = `${remotePath}/current`;
29
+ const sourcePath = `${remotePath}/source`;
30
+
31
+ // Pre-deploy hook
32
+ if (config.preDeploy) {
33
+ console.log(`[bm2] Running pre-deploy: ${config.preDeploy}`);
34
+ await this.localExec(config.preDeploy);
35
+ }
36
+
37
+ // Setup directory structure
38
+ await this.remoteExec(
39
+ target,
40
+ `mkdir -p ${remotePath} ${sourcePath}`,
41
+ sshOpts
42
+ );
43
+
44
+ // Clone or pull
45
+ const hasRepo = await this.remoteExec(
46
+ target,
47
+ `test -d ${sourcePath}/.git && echo "yes" || echo "no"`,
48
+ sshOpts
49
+ );
50
+
51
+ if (hasRepo.trim() === "yes") {
52
+ await this.remoteExec(
53
+ target,
54
+ `cd ${sourcePath} && git fetch --all && git reset --hard ${config.ref}`,
55
+ sshOpts
56
+ );
57
+ } else {
58
+ await this.remoteExec(
59
+ target,
60
+ `git clone ${config.repo} ${sourcePath} && cd ${sourcePath} && git checkout ${config.ref}`,
61
+ sshOpts
62
+ );
63
+ }
64
+
65
+ // Create release
66
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
67
+ const releasePath = `${remotePath}/releases/${timestamp}`;
68
+
69
+ await this.remoteExec(
70
+ target,
71
+ `mkdir -p ${remotePath}/releases && cp -r ${sourcePath} ${releasePath}`,
72
+ sshOpts
73
+ );
74
+
75
+ // Symlink current
76
+ await this.remoteExec(
77
+ target,
78
+ `rm -f ${currentPath} && ln -s ${releasePath} ${currentPath}`,
79
+ sshOpts
80
+ );
81
+
82
+ // Post-deploy hook
83
+ if (config.postDeploy) {
84
+ console.log(`[bm2] Running post-deploy: ${config.postDeploy}`);
85
+ const envStr = config.env
86
+ ? Object.entries(config.env)
87
+ .map(([k, v]) => `${k}=${v}`)
88
+ .join(" ")
89
+ : "";
90
+ await this.remoteExec(
91
+ target,
92
+ `cd ${currentPath} && ${envStr} ${config.postDeploy}`,
93
+ sshOpts
94
+ );
95
+ }
96
+
97
+ // Cleanup old releases (keep last 5)
98
+ await this.remoteExec(
99
+ target,
100
+ `cd ${remotePath}/releases && ls -dt */ | tail -n +6 | xargs rm -rf`,
101
+ sshOpts
102
+ );
103
+
104
+ console.log(`[bm2] ✓ Deploy to ${target} complete`);
105
+ }
106
+ }
107
+
108
+ async setup(config: DeployConfig): Promise<void> {
109
+ const hosts = Array.isArray(config.host) ? config.host : [config.host];
110
+ const sshOpts = config.ssh_options || "";
111
+
112
+ for (const host of hosts) {
113
+ const target = `${config.user}@${host}`;
114
+ console.log(`[bm2] Setting up ${target}...`);
115
+
116
+ await this.remoteExec(
117
+ target,
118
+ `mkdir -p ${config.path} ${config.path}/releases ${config.path}/source ${config.path}/shared`,
119
+ sshOpts
120
+ );
121
+
122
+ if (config.preSetup) {
123
+ await this.remoteExec(target, config.preSetup, sshOpts);
124
+ }
125
+
126
+ // Clone repo
127
+ await this.remoteExec(
128
+ target,
129
+ `git clone ${config.repo} ${config.path}/source && cd ${config.path}/source && git checkout ${config.ref}`,
130
+ sshOpts
131
+ );
132
+
133
+ if (config.postSetup) {
134
+ await this.remoteExec(
135
+ target,
136
+ `cd ${config.path}/source && ${config.postSetup}`,
137
+ sshOpts
138
+ );
139
+ }
140
+
141
+ console.log(`[bm2] ✓ Setup complete for ${target}`);
142
+ }
143
+ }
144
+
145
+ private async remoteExec(target: string, command: string, sshOpts: string): Promise<string> {
146
+ const args = ["ssh"];
147
+ if (sshOpts) args.push(...sshOpts.split(" "));
148
+ args.push(target, command);
149
+
150
+ const proc = Bun.spawn(args, {
151
+ stdout: "pipe",
152
+ stderr: "pipe",
153
+ });
154
+
155
+ const stdout = await new Response(proc.stdout).text();
156
+ const stderr = await new Response(proc.stderr).text();
157
+ const exitCode = await proc.exited;
158
+
159
+ if (exitCode !== 0 && stderr) {
160
+ console.error(`[bm2] Remote error: ${stderr}`);
161
+ }
162
+ if (stdout.trim()) {
163
+ console.log(stdout.trim());
164
+ }
165
+
166
+ return stdout;
167
+ }
168
+
169
+ private async localExec(command: string): Promise<string> {
170
+ const proc = Bun.spawn(["sh", "-c", command], {
171
+ stdout: "pipe",
172
+ stderr: "pipe",
173
+ });
174
+
175
+ const stdout = await new Response(proc.stdout).text();
176
+ const stderr = await new Response(proc.stderr).text();
177
+ const exitCode = await proc.exited;
178
+
179
+ if (exitCode !== 0 && stderr) {
180
+ console.error(`[bm2] Local error: ${stderr}`);
181
+ }
182
+ if (stdout.trim()) {
183
+ console.log(stdout.trim());
184
+ }
185
+
186
+ return stdout;
187
+ }
188
+ }