bgrun 3.12.3 → 3.12.5

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.
@@ -1292,6 +1292,23 @@ export default function mount(): () => void {
1292
1292
  else renderEnvPanel();
1293
1293
  }
1294
1294
 
1295
+ function updateLogExportLinks() {
1296
+ const textBtn = $('log-export-text-btn') as HTMLAnchorElement | null;
1297
+ const jsonBtn = $('log-export-json-btn') as HTMLAnchorElement | null;
1298
+ const csvBtn = $('log-export-csv-btn') as HTMLAnchorElement | null;
1299
+ const disabledHref = '#';
1300
+ if (!drawerProcess) {
1301
+ if (textBtn) textBtn.href = disabledHref;
1302
+ if (jsonBtn) jsonBtn.href = disabledHref;
1303
+ if (csvBtn) csvBtn.href = disabledHref;
1304
+ return;
1305
+ }
1306
+ const base = `/api/logs/${encodeURIComponent(drawerProcess)}?tab=${drawerTab}&offset=0&download=1`;
1307
+ if (textBtn) textBtn.href = `${base}&format=text`;
1308
+ if (jsonBtn) jsonBtn.href = `${base}&format=json`;
1309
+ if (csvBtn) csvBtn.href = `${base}&format=csv`;
1310
+ }
1311
+
1295
1312
  function switchLogSubtab(subtab: string, skipRefresh = false) {
1296
1313
  drawerTab = subtab as 'stdout' | 'stderr';
1297
1314
  $('log-subtabs')?.querySelectorAll('.accordion-subtab').forEach(btn => {
@@ -1305,6 +1322,7 @@ export default function mount(): () => void {
1305
1322
  logNeedsFullRebuild = true;
1306
1323
  logVirtualActive = false;
1307
1324
  logFilteredIndices = [];
1325
+ updateLogExportLinks();
1308
1326
  if (!skipRefresh) refreshDrawerLogs();
1309
1327
  }
1310
1328
 
@@ -1354,6 +1372,7 @@ export default function mount(): () => void {
1354
1372
  function openDrawer(name: string) {
1355
1373
  drawerProcess = name;
1356
1374
  drawerTab = 'stdout';
1375
+ updateLogExportLinks();
1357
1376
 
1358
1377
  // Update header
1359
1378
  const nameEl = $('drawer-process-name');
@@ -1501,6 +1520,7 @@ export default function mount(): () => void {
1501
1520
  drawer?.classList.remove('open');
1502
1521
  backdrop?.classList.remove('active');
1503
1522
  drawerProcess = null;
1523
+ updateLogExportLinks();
1504
1524
  tbody?.querySelectorAll('tr.selected').forEach(r => r.classList.remove('selected'));
1505
1525
  }
1506
1526
 
@@ -2559,6 +2579,21 @@ export default function mount(): () => void {
2559
2579
  }
2560
2580
  }
2561
2581
 
2582
+ function updateHistoryExportLinks() {
2583
+ const processFilter = $('history-process-filter') as HTMLSelectElement | null;
2584
+ const eventFilter = $('history-event-filter') as HTMLSelectElement | null;
2585
+ const metadataFilter = $('history-metadata-filter') as HTMLInputElement | null;
2586
+ const params = new URLSearchParams({ limit: '100', download: '1' });
2587
+ if (processFilter?.value) params.set('name', processFilter.value);
2588
+ if (eventFilter?.value) params.set('event', eventFilter.value);
2589
+ if (metadataFilter?.value.trim()) params.set('metadata', metadataFilter.value.trim());
2590
+
2591
+ const jsonBtn = $('history-export-json-btn') as HTMLAnchorElement | null;
2592
+ const csvBtn = $('history-export-csv-btn') as HTMLAnchorElement | null;
2593
+ if (jsonBtn) jsonBtn.href = `/api/history?${params.toString()}&format=json`;
2594
+ if (csvBtn) csvBtn.href = `/api/history?${params.toString()}&format=csv`;
2595
+ }
2596
+
2562
2597
  function updateHistoryFilters() {
2563
2598
  const processFilter = $('history-process-filter') as HTMLSelectElement;
2564
2599
  const eventFilter = $('history-event-filter') as HTMLSelectElement;
@@ -2827,6 +2862,7 @@ export default function mount(): () => void {
2827
2862
  }
2828
2863
 
2829
2864
  updateHistoryClearButton();
2865
+ updateHistoryExportLinks();
2830
2866
  applyHistoryDensity();
2831
2867
  applyHistoryShortcutPreference();
2832
2868
  applyHistoryDetailsPreference();
@@ -266,6 +266,11 @@ export default function DashboardPage() {
266
266
  <div className="drawer-log-toolbar" id="drawer-log-toolbar">
267
267
  <input type="text" id="log-search" className="log-search" placeholder="Filter logs..." />
268
268
  <span className="log-line-count" id="log-line-count"></span>
269
+ <div className="toolbar-export-actions">
270
+ <a id="log-export-text-btn" className="btn btn-ghost btn-sm" href="#" target="_blank" rel="noopener">Export .log</a>
271
+ <a id="log-export-json-btn" className="btn btn-ghost btn-sm" href="#" target="_blank" rel="noopener">Export JSON</a>
272
+ <a id="log-export-csv-btn" className="btn btn-ghost btn-sm" href="#" target="_blank" rel="noopener">Export CSV</a>
273
+ </div>
269
274
  <button id="log-autoscroll-btn" className="log-autoscroll" title="Auto-scroll: OFF">
270
275
  <svg viewBox="0 0 24 24"><path d="M12 5v14M5 12l7 7 7-7" /></svg>
271
276
  Follow
@@ -387,6 +392,10 @@ export default function DashboardPage() {
387
392
  </select>
388
393
  </label>
389
394
  <button className="btn btn-ghost btn-sm" id="history-clear-filters-btn" title="Clear all history filters">Clear</button>
395
+ <div className="toolbar-export-actions">
396
+ <a id="history-export-json-btn" className="btn btn-ghost btn-sm" href="#" target="_blank" rel="noopener">Export JSON</a>
397
+ <a id="history-export-csv-btn" className="btn btn-ghost btn-sm" href="#" target="_blank" rel="noopener">Export CSV</a>
398
+ </div>
390
399
  </div>
391
400
  <div className="history-hints-bar">
392
401
  <div className="history-hints-bar-left">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bgrun",
3
- "version": "3.12.3",
3
+ "version": "3.12.5",
4
4
  "description": "bgrun — A lightweight process manager for Bun",
5
5
  "type": "module",
6
6
  "main": "./src/api.ts",
@@ -8,7 +8,7 @@
8
8
  ".": "./src/api.ts"
9
9
  },
10
10
  "bin": {
11
- "bgrun": "./dist/index.js"
11
+ "bgrun": "dist/index.js"
12
12
  },
13
13
  "scripts": {
14
14
  "build": "bun run ./src/build.ts",
@@ -17,7 +17,22 @@
17
17
  },
18
18
  "files": [
19
19
  "dist",
20
- "src",
20
+ "src/api.ts",
21
+ "src/build.ts",
22
+ "src/config.ts",
23
+ "src/db.ts",
24
+ "src/deploy.ts",
25
+ "src/deps.ts",
26
+ "src/guard.ts",
27
+ "src/index.ts",
28
+ "src/log-rotation.ts",
29
+ "src/logger.ts",
30
+ "src/platform.ts",
31
+ "src/server.ts",
32
+ "src/table.ts",
33
+ "src/types.ts",
34
+ "src/utils.ts",
35
+ "src/commands",
21
36
  "dashboard/app",
22
37
  "scripts",
23
38
  "README.md",
@@ -37,7 +52,7 @@
37
52
  "license": "MIT",
38
53
  "repository": {
39
54
  "type": "git",
40
- "url": "https://github.com/7flash/bgrun.git"
55
+ "url": "git+https://github.com/7flash/bgrun.git"
41
56
  },
42
57
  "devDependencies": {
43
58
  "@types/bun": "^1.3.10",
package/src/bgrun.test.ts DELETED
@@ -1,313 +0,0 @@
1
- /**
2
- * bgrun core utility tests
3
- *
4
- * Tests pure logic functions: env parsing, config flattening,
5
- * string truncation, and runtime calculation.
6
- *
7
- * Run: bun test src/bgrun.test.ts
8
- */
9
- import { describe, expect, test } from 'bun:test'
10
- import { parseEnvString, calculateRuntime } from './utils'
11
- import { stripAnsi, truncateString, truncatePath } from './table'
12
- import { detectPackageManager, formatDeployToolError } from './deploy'
13
- import { isProcessRunning, parseUnixListeningPorts } from './platform'
14
- import { mkdirSync, rmSync } from 'fs'
15
-
16
- // Use a test-specific database to avoid polluting real data
17
- process.env.BGRUN_DB = `bgrun-test-${Date.now()}.sqlite`
18
- import { addDependency, removeDependency, getDependencyGraph, getDependencies, getDependents, getStartOrder, removeAllDependencies } from './db'
19
-
20
- // ─── parseEnvString ─────────────────────────────────────
21
-
22
- describe('parseEnvString', () => {
23
- test('parses comma-separated key=value pairs', () => {
24
- const result = parseEnvString('PORT=3000,HOST=localhost,DEBUG=true')
25
- expect(result).toEqual({
26
- PORT: '3000',
27
- HOST: 'localhost',
28
- DEBUG: 'true',
29
- })
30
- })
31
-
32
- test('handles single pair', () => {
33
- expect(parseEnvString('KEY=value')).toEqual({ KEY: 'value' })
34
- })
35
-
36
- test('handles empty string', () => {
37
- expect(parseEnvString('')).toEqual({})
38
- })
39
-
40
- test('ignores malformed pairs (no =)', () => {
41
- const result = parseEnvString('GOOD=yes,BAD,ALSO_GOOD=ok')
42
- expect(result.GOOD).toBe('yes')
43
- expect(result.ALSO_GOOD).toBe('ok')
44
- expect(result.BAD).toBeUndefined()
45
- })
46
- })
47
-
48
- // ─── calculateRuntime ───────────────────────────────────
49
-
50
- describe('calculateRuntime', () => {
51
- test('returns 0 minutes for recent start', () => {
52
- const now = new Date().toISOString()
53
- expect(calculateRuntime(now)).toBe('0 minutes')
54
- })
55
-
56
- test('returns correct minutes', () => {
57
- const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
58
- expect(calculateRuntime(fiveMinAgo)).toBe('5 minutes')
59
- })
60
-
61
- test('returns correct for 1 hour', () => {
62
- const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString()
63
- expect(calculateRuntime(oneHourAgo)).toBe('60 minutes')
64
- })
65
- })
66
-
67
- // ─── stripAnsi ──────────────────────────────────────────
68
-
69
- describe('stripAnsi', () => {
70
- test('strips color codes', () => {
71
- const colored = '\u001b[31mred text\u001b[0m'
72
- expect(stripAnsi(colored)).toBe('red text')
73
- })
74
-
75
- test('passes through plain text', () => {
76
- expect(stripAnsi('hello world')).toBe('hello world')
77
- })
78
-
79
- test('handles empty string', () => {
80
- expect(stripAnsi('')).toBe('')
81
- })
82
- })
83
-
84
- // ─── truncateString ─────────────────────────────────────
85
-
86
- describe('truncateString', () => {
87
- test('returns string unchanged if within limit', () => {
88
- expect(truncateString('hello', 10)).toBe('hello')
89
- })
90
-
91
- test('truncates with ellipsis', () => {
92
- const result = truncateString('a very long string that exceeds limit', 15)
93
- expect(result.length).toBeLessThanOrEqual(15)
94
- expect(result).toContain('…')
95
- })
96
-
97
- test('handles maxLength smaller than ellipsis', () => {
98
- const result = truncateString('hello world', 2)
99
- expect(result.length).toBeLessThanOrEqual(2)
100
- })
101
- })
102
-
103
- // ─── truncatePath ───────────────────────────────────────
104
-
105
- describe('truncatePath', () => {
106
- test('returns path unchanged if within limit', () => {
107
- expect(truncatePath('/home/user', 50)).toBe('/home/user')
108
- })
109
-
110
- test('truncates middle of long path', () => {
111
- const longPath = '/home/user/projects/very/deeply/nested/directory/structure'
112
- const result = truncatePath(longPath, 30)
113
- expect(result.length).toBeLessThanOrEqual(30)
114
- expect(result).toContain('…')
115
- })
116
- })
117
-
118
- // ─── detectPackageManager ───────────────────────────────
119
-
120
- // ─── isProcessRunning (Windows liveness fallback) ───────
121
-
122
- describe('isProcessRunning', () => {
123
- test('returns true for the current process PID', async () => {
124
- const alive = await isProcessRunning(process.pid)
125
- expect(alive).toBe(true)
126
- })
127
-
128
- test('returns false for PID 0 (intentionally stopped)', async () => {
129
- const alive = await isProcessRunning(0)
130
- expect(alive).toBe(false)
131
- })
132
-
133
- test('returns false for a very high unlikely PID', async () => {
134
- const alive = await isProcessRunning(999999)
135
- expect(alive).toBe(false)
136
- })
137
-
138
- test('returns false for negative PID', async () => {
139
- const alive = await isProcessRunning(-1)
140
- expect(alive).toBe(false)
141
- })
142
- })
143
-
144
- describe('parseUnixListeningPorts', () => {
145
- test('extracts only LISTEN ports from lsof output', () => {
146
- const output = [
147
- 'COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME',
148
- 'bun 12345 root 21u IPv4 123456 0t0 TCP *:3400 (LISTEN)',
149
- 'bun 12345 root 22u IPv4 123457 0t0 TCP 127.0.0.1:9222 (LISTEN)',
150
- ].join('\n')
151
-
152
- expect(parseUnixListeningPorts(output)).toEqual([3400, 9222])
153
- })
154
-
155
- test('ignores non-LISTEN sockets from broad lsof output', () => {
156
- const output = [
157
- 'COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME',
158
- 'bun 12345 root 18u IPv4 111111 0t0 TCP 127.0.0.1:49440->127.0.0.1:3000 (ESTABLISHED)',
159
- 'bun 12345 root 19u IPv4 111112 0t0 TCP 127.0.0.1:49441->127.0.0.1:3737 (ESTABLISHED)',
160
- 'bun 12345 root 20u IPv4 111113 0t0 TCP *:3400 (LISTEN)',
161
- ].join('\n')
162
-
163
- expect(parseUnixListeningPorts(output)).toEqual([3400])
164
- })
165
-
166
- test('returns empty array for no-port worker output', () => {
167
- const output = [
168
- 'COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME',
169
- 'bun 12345 root 18u unix 0xffff 0t0 /tmp/bun.sock',
170
- 'bun 12345 root 19u IPv4 111111 0t0 TCP 127.0.0.1:49440->127.0.0.1:3000 (ESTABLISHED)',
171
- ].join('\n')
172
-
173
- expect(parseUnixListeningPorts(output)).toEqual([])
174
- })
175
- })
176
-
177
- // ─── detectPackageManager ───────────────────────────────
178
-
179
- describe('formatDeployToolError', () => {
180
- test('returns actionable message for missing binary', () => {
181
- const msg = formatDeployToolError('pnpm', new Error('command not found: pnpm'))
182
- expect(msg).toContain("requires 'pnpm'")
183
- expect(msg).toContain('PATH')
184
- })
185
-
186
- test('preserves non-missing-binary failures', () => {
187
- const msg = formatDeployToolError('npm', new Error('npm ci failed with exit code 1'))
188
- expect(msg).toContain('Dependency install failed with npm')
189
- expect(msg).toContain('exit code 1')
190
- })
191
- })
192
-
193
- describe('detectPackageManager', () => {
194
- test('returns null when no package.json exists', async () => {
195
- const dir = `${process.cwd()}/tmp-no-package-${Date.now()}`
196
- mkdirSync(dir, { recursive: true })
197
- try {
198
- expect(await detectPackageManager(dir)).toBeNull()
199
- } finally {
200
- rmSync(dir, { recursive: true, force: true })
201
- }
202
- })
203
-
204
- test('prefers bun lockfiles', async () => {
205
- const dir = `${process.cwd()}/tmp-bun-${Date.now()}`
206
- mkdirSync(dir, { recursive: true })
207
- try {
208
- await Bun.write(`${dir}/package.json`, '{}')
209
- await Bun.write(`${dir}/bun.lock`, '')
210
- expect(await detectPackageManager(dir)).toBe('bun')
211
- } finally {
212
- rmSync(dir, { recursive: true, force: true })
213
- }
214
- })
215
-
216
- test('detects pnpm, yarn, and npm lockfiles', async () => {
217
- const base = `${process.cwd()}/tmp-pm-${Date.now()}`
218
-
219
- const pnpmDir = `${base}-pnpm`
220
- mkdirSync(pnpmDir, { recursive: true })
221
- await Bun.write(`${pnpmDir}/package.json`, '{}')
222
- await Bun.write(`${pnpmDir}/pnpm-lock.yaml`, '')
223
- expect(await detectPackageManager(pnpmDir)).toBe('pnpm')
224
-
225
- const yarnDir = `${base}-yarn`
226
- mkdirSync(yarnDir, { recursive: true })
227
- await Bun.write(`${yarnDir}/package.json`, '{}')
228
- await Bun.write(`${yarnDir}/yarn.lock`, '')
229
- expect(await detectPackageManager(yarnDir)).toBe('yarn')
230
-
231
- const npmDir = `${base}-npm`
232
- mkdirSync(npmDir, { recursive: true })
233
- await Bun.write(`${npmDir}/package.json`, '{}')
234
- await Bun.write(`${npmDir}/package-lock.json`, '{}')
235
- expect(await detectPackageManager(npmDir)).toBe('npm')
236
-
237
- rmSync(pnpmDir, { recursive: true, force: true })
238
- rmSync(yarnDir, { recursive: true, force: true })
239
- rmSync(npmDir, { recursive: true, force: true })
240
- })
241
-
242
- test('defaults to bun for package.json projects without a lockfile', async () => {
243
- const dir = `${process.cwd()}/tmp-default-bun-${Date.now()}`
244
- mkdirSync(dir, { recursive: true })
245
- try {
246
- await Bun.write(`${dir}/package.json`, '{}')
247
- expect(await detectPackageManager(dir)).toBe('bun')
248
- } finally {
249
- rmSync(dir, { recursive: true, force: true })
250
- }
251
- })
252
- })
253
-
254
- // ─── Dependencies ───────────────────────────────────────
255
-
256
- describe('addDependency', () => {
257
- test('adds a valid dependency', () => {
258
- removeAllDependencies('web-server');
259
- removeAllDependencies('database');
260
- const ok = addDependency('web-server', 'database');
261
- expect(ok).toBe(true);
262
- expect(getDependencies('web-server')).toContain('database');
263
- })
264
-
265
- test('prevents self-dependency', () => {
266
- expect(addDependency('api', 'api')).toBe(false);
267
- })
268
-
269
- test('prevents duplicate dependency', () => {
270
- removeAllDependencies('app');
271
- addDependency('app', 'db');
272
- expect(addDependency('app', 'db')).toBe(false);
273
- })
274
-
275
- test('prevents circular dependency', () => {
276
- removeAllDependencies('a');
277
- removeAllDependencies('b');
278
- removeAllDependencies('c');
279
- addDependency('a', 'b');
280
- addDependency('b', 'c');
281
- // c -> a would create a cycle
282
- expect(addDependency('c', 'a')).toBe(false);
283
- })
284
- })
285
-
286
- describe('getDependencyGraph', () => {
287
- test('returns full graph', () => {
288
- removeAllDependencies('svc-a');
289
- removeAllDependencies('svc-b');
290
- addDependency('svc-a', 'svc-b');
291
- const graph = getDependencyGraph();
292
- expect(graph['svc-a']).toContain('svc-b');
293
- })
294
- })
295
-
296
- describe('getDependents', () => {
297
- test('finds processes that depend on a target', () => {
298
- removeAllDependencies('frontend');
299
- removeAllDependencies('backend');
300
- addDependency('frontend', 'backend');
301
- expect(getDependents('backend')).toContain('frontend');
302
- })
303
- })
304
-
305
- describe('removeDependency', () => {
306
- test('removes an existing dependency', () => {
307
- removeAllDependencies('x');
308
- addDependency('x', 'y');
309
- expect(getDependencies('x')).toContain('y');
310
- removeDependency('x', 'y');
311
- expect(getDependencies('x')).not.toContain('y');
312
- })
313
- })