bgrun 3.12.1 → 3.12.3
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/dashboard/app/api/check-port/route.ts +35 -0
- package/dashboard/app/api/dependencies/route.ts +40 -0
- package/dashboard/app/api/deploy/[name]/route.ts +6 -41
- package/dashboard/app/api/deploy-all/route.ts +25 -0
- package/dashboard/app/api/next-port/route.ts +32 -0
- package/dashboard/app/api/start/route.ts +6 -0
- package/dashboard/app/globals.css +1313 -108
- package/dashboard/app/page.client.tsx +1576 -8
- package/dashboard/app/page.tsx +194 -5
- package/dist/index.js +143 -25
- package/package.json +1 -1
- package/src/bgrun.test.ts +204 -0
- package/src/db.ts +142 -0
- package/src/deploy.ts +163 -0
- package/src/index.ts +9 -0
- package/src/platform.ts +29 -14
package/src/bgrun.test.ts
CHANGED
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
import { describe, expect, test } from 'bun:test'
|
|
10
10
|
import { parseEnvString, calculateRuntime } from './utils'
|
|
11
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'
|
|
12
19
|
|
|
13
20
|
// ─── parseEnvString ─────────────────────────────────────
|
|
14
21
|
|
|
@@ -107,3 +114,200 @@ describe('truncatePath', () => {
|
|
|
107
114
|
expect(result).toContain('…')
|
|
108
115
|
})
|
|
109
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
|
+
})
|
package/src/db.ts
CHANGED
|
@@ -44,6 +44,14 @@ export const HistorySchema = z.object({
|
|
|
44
44
|
|
|
45
45
|
export type History = z.infer<typeof HistorySchema> & { id: number };
|
|
46
46
|
|
|
47
|
+
export const DependencySchema = z.object({
|
|
48
|
+
process_name: z.string(), // the process that has the dependency
|
|
49
|
+
depends_on: z.string(), // the process it depends on
|
|
50
|
+
created_at: z.string().default(() => new Date().toISOString()),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export type Dependency = z.infer<typeof DependencySchema> & { id: number };
|
|
54
|
+
|
|
47
55
|
// =============================================================================
|
|
48
56
|
// DATABASE INITIALIZATION
|
|
49
57
|
// =============================================================================
|
|
@@ -72,11 +80,13 @@ export const db = new Database(dbPath, {
|
|
|
72
80
|
process: ProcessSchema,
|
|
73
81
|
template: TemplateSchema,
|
|
74
82
|
history: HistorySchema,
|
|
83
|
+
dependency: DependencySchema,
|
|
75
84
|
}, {
|
|
76
85
|
indexes: {
|
|
77
86
|
process: ['name', 'timestamp', 'pid'],
|
|
78
87
|
template: ['name'],
|
|
79
88
|
history: ['process_name', 'timestamp'],
|
|
89
|
+
dependency: ['process_name', 'depends_on'],
|
|
80
90
|
},
|
|
81
91
|
});
|
|
82
92
|
|
|
@@ -242,6 +252,138 @@ export function clearOldHistory(daysToKeep = 30) {
|
|
|
242
252
|
return oldEntries.length;
|
|
243
253
|
}
|
|
244
254
|
|
|
255
|
+
// =============================================================================
|
|
256
|
+
// DEPENDENCY FUNCTIONS
|
|
257
|
+
// =============================================================================
|
|
258
|
+
|
|
259
|
+
/** Get all dependencies for a process */
|
|
260
|
+
export function getDependencies(processName: string): string[] {
|
|
261
|
+
return db.dependency.select()
|
|
262
|
+
.where({ process_name: processName })
|
|
263
|
+
.all()
|
|
264
|
+
.map(d => d.depends_on);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Get all processes that depend on a given process */
|
|
268
|
+
export function getDependents(processName: string): string[] {
|
|
269
|
+
return db.dependency.select()
|
|
270
|
+
.where({ depends_on: processName })
|
|
271
|
+
.all()
|
|
272
|
+
.map(d => d.process_name);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Get the full dependency graph: { processName -> [dependsOn...] } */
|
|
276
|
+
export function getDependencyGraph(): Record<string, string[]> {
|
|
277
|
+
const all = db.dependency.select().all();
|
|
278
|
+
const graph: Record<string, string[]> = {};
|
|
279
|
+
for (const dep of all) {
|
|
280
|
+
if (!graph[dep.process_name]) graph[dep.process_name] = [];
|
|
281
|
+
graph[dep.process_name].push(dep.depends_on);
|
|
282
|
+
}
|
|
283
|
+
return graph;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Add a dependency (process_name depends on depends_on) */
|
|
287
|
+
export function addDependency(processName: string, dependsOn: string): boolean {
|
|
288
|
+
// Prevent self-dependency
|
|
289
|
+
if (processName === dependsOn) return false;
|
|
290
|
+
|
|
291
|
+
// Prevent duplicates
|
|
292
|
+
const existing = db.dependency.select()
|
|
293
|
+
.where({ process_name: processName, depends_on: dependsOn })
|
|
294
|
+
.limit(1)
|
|
295
|
+
.get();
|
|
296
|
+
if (existing) return false;
|
|
297
|
+
|
|
298
|
+
// Prevent circular dependencies
|
|
299
|
+
if (wouldCreateCycle(processName, dependsOn)) return false;
|
|
300
|
+
|
|
301
|
+
db.dependency.insert({ process_name: processName, depends_on: dependsOn });
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Remove a dependency */
|
|
306
|
+
export function removeDependency(processName: string, dependsOn: string) {
|
|
307
|
+
const matches = db.dependency.select()
|
|
308
|
+
.where({ process_name: processName, depends_on: dependsOn })
|
|
309
|
+
.all();
|
|
310
|
+
for (const dep of matches) {
|
|
311
|
+
db.dependency.delete(dep.id);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Remove all dependencies for a process */
|
|
316
|
+
export function removeAllDependencies(processName: string) {
|
|
317
|
+
const matches = db.dependency.select()
|
|
318
|
+
.where({ process_name: processName })
|
|
319
|
+
.all();
|
|
320
|
+
for (const dep of matches) {
|
|
321
|
+
db.dependency.delete(dep.id);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Check if adding processName -> dependsOn would create a cycle */
|
|
326
|
+
function wouldCreateCycle(processName: string, dependsOn: string): boolean {
|
|
327
|
+
const graph = getDependencyGraph();
|
|
328
|
+
// Add the proposed edge temporarily
|
|
329
|
+
if (!graph[processName]) graph[processName] = [];
|
|
330
|
+
graph[processName].push(dependsOn);
|
|
331
|
+
|
|
332
|
+
// DFS from dependsOn — if we can reach processName, it's a cycle
|
|
333
|
+
const visited = new Set<string>();
|
|
334
|
+
const stack = [dependsOn];
|
|
335
|
+
while (stack.length > 0) {
|
|
336
|
+
const current = stack.pop()!;
|
|
337
|
+
if (current === processName) return true;
|
|
338
|
+
if (visited.has(current)) continue;
|
|
339
|
+
visited.add(current);
|
|
340
|
+
for (const dep of (graph[current] || [])) {
|
|
341
|
+
stack.push(dep);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Get topological start order (processes with no deps first) */
|
|
348
|
+
export function getStartOrder(): string[] {
|
|
349
|
+
const graph = getDependencyGraph();
|
|
350
|
+
const allProcesses = getAllProcesses().map(p => p.name);
|
|
351
|
+
const allNames = new Set(allProcesses);
|
|
352
|
+
|
|
353
|
+
// Build in-degree map
|
|
354
|
+
const inDegree: Record<string, number> = {};
|
|
355
|
+
for (const name of allNames) inDegree[name] = 0;
|
|
356
|
+
for (const [proc, deps] of Object.entries(graph)) {
|
|
357
|
+
for (const dep of deps) {
|
|
358
|
+
if (allNames.has(dep)) {
|
|
359
|
+
inDegree[proc] = (inDegree[proc] || 0) + 1;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Kahn's algorithm
|
|
365
|
+
const queue: string[] = [];
|
|
366
|
+
for (const name of allNames) {
|
|
367
|
+
if ((inDegree[name] || 0) === 0) queue.push(name);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const order: string[] = [];
|
|
371
|
+
while (queue.length > 0) {
|
|
372
|
+
queue.sort(); // stable alphabetical within same level
|
|
373
|
+
const current = queue.shift()!;
|
|
374
|
+
order.push(current);
|
|
375
|
+
// Find processes that depend on current
|
|
376
|
+
for (const [proc, deps] of Object.entries(graph)) {
|
|
377
|
+
if (deps.includes(current) && allNames.has(proc)) {
|
|
378
|
+
inDegree[proc]--;
|
|
379
|
+
if (inDegree[proc] === 0) queue.push(proc);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return order;
|
|
385
|
+
}
|
|
386
|
+
|
|
245
387
|
// =============================================================================
|
|
246
388
|
// DEBUG / INFO
|
|
247
389
|
// =============================================================================
|
package/src/deploy.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { getProcess, getAllProcesses, addHistoryEntry } from './db';
|
|
2
|
+
import { handleRun } from './commands/run';
|
|
3
|
+
import { $ } from 'bun';
|
|
4
|
+
|
|
5
|
+
export interface DeployResult {
|
|
6
|
+
name: string;
|
|
7
|
+
ok: boolean;
|
|
8
|
+
skipped?: boolean;
|
|
9
|
+
reason?: string;
|
|
10
|
+
pullOutput?: string;
|
|
11
|
+
installOutput?: string;
|
|
12
|
+
packageManager?: PackageManager;
|
|
13
|
+
installCommand?: string;
|
|
14
|
+
installAttempted?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type PackageManager = 'bun' | 'pnpm' | 'yarn' | 'npm' | null;
|
|
18
|
+
|
|
19
|
+
export function formatDeployToolError(manager: Exclude<PackageManager, null>, error: unknown): string {
|
|
20
|
+
const raw = error instanceof Error ? error.message : String(error ?? 'Unknown error');
|
|
21
|
+
const lower = raw.toLowerCase();
|
|
22
|
+
|
|
23
|
+
if (
|
|
24
|
+
lower.includes('command not found') ||
|
|
25
|
+
lower.includes('not recognized as an internal or external command') ||
|
|
26
|
+
lower.includes('executable not found') ||
|
|
27
|
+
lower.includes('no such file or directory')
|
|
28
|
+
) {
|
|
29
|
+
return `Deploy requires '${manager}', but it is not installed or not available on PATH.`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return `Dependency install failed with ${manager}: ${raw}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isInternalProcess(name: string): boolean {
|
|
36
|
+
return name === 'bgr-dashboard' || name === 'bgr-guard';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
40
|
+
return await Bun.file(path).exists();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function isGitRepo(dir: string): Promise<boolean> {
|
|
44
|
+
return await pathExists(`${dir}/.git`) || await pathExists(`${dir}/.git/HEAD`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function detectPackageManager(dir: string): Promise<PackageManager> {
|
|
48
|
+
const hasPackageJson = await pathExists(`${dir}/package.json`);
|
|
49
|
+
if (!hasPackageJson) return null;
|
|
50
|
+
|
|
51
|
+
if (await pathExists(`${dir}/bun.lock`) || await pathExists(`${dir}/bun.lockb`)) return 'bun';
|
|
52
|
+
if (await pathExists(`${dir}/pnpm-lock.yaml`)) return 'pnpm';
|
|
53
|
+
if (await pathExists(`${dir}/yarn.lock`)) return 'yarn';
|
|
54
|
+
if (await pathExists(`${dir}/package-lock.json`) || await pathExists(`${dir}/npm-shrinkwrap.json`)) return 'npm';
|
|
55
|
+
|
|
56
|
+
return 'bun';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getInstallCommand(manager: Exclude<PackageManager, null>): string {
|
|
60
|
+
switch (manager) {
|
|
61
|
+
case 'bun': return 'bun install';
|
|
62
|
+
case 'pnpm': return 'pnpm install --frozen-lockfile';
|
|
63
|
+
case 'yarn': return 'yarn install --frozen-lockfile';
|
|
64
|
+
case 'npm': return 'npm ci';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function installDependencies(dir: string): Promise<{ manager: PackageManager; output: string; command: string }> {
|
|
69
|
+
const manager = await detectPackageManager(dir);
|
|
70
|
+
if (!manager) return { manager: null, output: '', command: '' };
|
|
71
|
+
|
|
72
|
+
$.cwd(dir);
|
|
73
|
+
const command = getInstallCommand(manager);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
switch (manager) {
|
|
77
|
+
case 'bun':
|
|
78
|
+
return { manager, command, output: (await $`bun install`.text()).trim() };
|
|
79
|
+
case 'pnpm':
|
|
80
|
+
return { manager, command, output: (await $`pnpm install --frozen-lockfile`.text()).trim() };
|
|
81
|
+
case 'yarn':
|
|
82
|
+
return { manager, command, output: (await $`yarn install --frozen-lockfile`.text()).trim() };
|
|
83
|
+
case 'npm':
|
|
84
|
+
return { manager, command, output: (await $`npm ci`.text()).trim() };
|
|
85
|
+
default:
|
|
86
|
+
return { manager: null, output: '', command: '' };
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new Error(formatDeployToolError(manager, error));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function deployProcess(name: string): Promise<DeployResult> {
|
|
94
|
+
const proc = getProcess(name);
|
|
95
|
+
if (!proc) {
|
|
96
|
+
return { name, ok: false, reason: `Process '${name}' not found` };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (isInternalProcess(proc.name)) {
|
|
100
|
+
return { name, ok: false, skipped: true, reason: 'Internal bgrun process skipped' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const dir = proc.workdir;
|
|
104
|
+
if (!(await isGitRepo(dir))) {
|
|
105
|
+
return { name, ok: false, skipped: true, reason: `'${dir}' is not a git repository` };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
$.cwd(dir);
|
|
110
|
+
|
|
111
|
+
const pullOutput = (await $`git pull`.text()).trim();
|
|
112
|
+
|
|
113
|
+
const install = await installDependencies(dir);
|
|
114
|
+
const installOutput = install.output;
|
|
115
|
+
|
|
116
|
+
await handleRun({
|
|
117
|
+
action: 'run',
|
|
118
|
+
name,
|
|
119
|
+
force: true,
|
|
120
|
+
remoteName: '',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
addHistoryEntry(name, 'deploy', proc.pid, {
|
|
124
|
+
directory: dir,
|
|
125
|
+
installed: Boolean(install.manager),
|
|
126
|
+
packageManager: install.manager,
|
|
127
|
+
installCommand: install.command,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
name,
|
|
132
|
+
ok: true,
|
|
133
|
+
pullOutput,
|
|
134
|
+
installOutput,
|
|
135
|
+
packageManager: install.manager,
|
|
136
|
+
installCommand: install.command,
|
|
137
|
+
installAttempted: Boolean(install.manager),
|
|
138
|
+
};
|
|
139
|
+
} catch (e: any) {
|
|
140
|
+
return {
|
|
141
|
+
name,
|
|
142
|
+
ok: false,
|
|
143
|
+
reason: e?.message || String(e),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function deployAllProcesses(group?: string): Promise<DeployResult[]> {
|
|
149
|
+
const processes = getAllProcesses()
|
|
150
|
+
.filter(proc => !isInternalProcess(proc.name))
|
|
151
|
+
.filter(proc => !group || proc.group === group);
|
|
152
|
+
|
|
153
|
+
const seen = new Set<string>();
|
|
154
|
+
const results: DeployResult[] = [];
|
|
155
|
+
|
|
156
|
+
for (const proc of processes) {
|
|
157
|
+
if (seen.has(proc.name)) continue;
|
|
158
|
+
seen.add(proc.name);
|
|
159
|
+
results.push(await deployProcess(proc.name));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return results;
|
|
163
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -574,6 +574,15 @@ async function run() {
|
|
|
574
574
|
return;
|
|
575
575
|
}
|
|
576
576
|
|
|
577
|
+
// Explicit "list" command
|
|
578
|
+
if (name === 'list') {
|
|
579
|
+
await showAll({
|
|
580
|
+
json: values.json as boolean | undefined,
|
|
581
|
+
filter: values.filter as string | undefined
|
|
582
|
+
});
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
577
586
|
// List or Run or Details
|
|
578
587
|
if (name) {
|
|
579
588
|
if (!values.command && !values.directory) {
|
package/src/platform.ts
CHANGED
|
@@ -61,9 +61,19 @@ export async function isProcessRunning(pid: number, command?: string): Promise<b
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
if (isWindows()) {
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
64
|
+
// Fast path: signal 0 works for many native Windows/Bun invocations.
|
|
65
|
+
// But under MSYS/Git Bash or detached wrapper scenarios it can return
|
|
66
|
+
// false negatives for live Windows PIDs. Fall back to Get-Process so
|
|
67
|
+
// CLI, dashboard, and guard all agree on process liveness.
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0);
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
const output = psExec(
|
|
73
|
+
`Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id`
|
|
74
|
+
).trim();
|
|
75
|
+
return output === String(pid);
|
|
76
|
+
}
|
|
67
77
|
} else {
|
|
68
78
|
const result = await $`ps -p ${pid}`.nothrow().text();
|
|
69
79
|
return result.includes(`${pid}`);
|
|
@@ -598,6 +608,20 @@ export async function getProcessBatchResources(pids: number[]): Promise<Map<numb
|
|
|
598
608
|
}) ?? new Map();
|
|
599
609
|
}
|
|
600
610
|
|
|
611
|
+
/**
|
|
612
|
+
* Parse Unix lsof LISTEN output and return only true listening TCP ports.
|
|
613
|
+
*/
|
|
614
|
+
export function parseUnixListeningPorts(output: string): number[] {
|
|
615
|
+
const ports = new Set<number>();
|
|
616
|
+
for (const line of output.split('\n')) {
|
|
617
|
+
const portMatch = line.match(/:(\d+)\s+\(LISTEN\)/);
|
|
618
|
+
if (portMatch) {
|
|
619
|
+
ports.add(parseInt(portMatch[1]));
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return Array.from(ports);
|
|
623
|
+
}
|
|
624
|
+
|
|
601
625
|
/**
|
|
602
626
|
* Get the TCP ports a process is currently listening on by querying the OS.
|
|
603
627
|
* Returns an array of port numbers (empty if none or process not found).
|
|
@@ -632,17 +656,8 @@ export async function getProcessPorts(pid: number): Promise<number[]> {
|
|
|
632
656
|
if (ports.size > 0) return Array.from(ports);
|
|
633
657
|
} catch { /* ss not available, try lsof */ }
|
|
634
658
|
|
|
635
|
-
const result = await $`lsof -
|
|
636
|
-
|
|
637
|
-
for (const line of result.split('\n')) {
|
|
638
|
-
if (line.includes('LISTEN')) {
|
|
639
|
-
const portMatch = line.match(/:(\d+)\s+\(LISTEN\)/);
|
|
640
|
-
if (portMatch) {
|
|
641
|
-
ports.add(parseInt(portMatch[1]));
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
return Array.from(ports);
|
|
659
|
+
const result = await $`lsof -Pan -p ${pid} -iTCP -sTCP:LISTEN`.nothrow().quiet().text();
|
|
660
|
+
return parseUnixListeningPorts(result);
|
|
646
661
|
}
|
|
647
662
|
} catch {
|
|
648
663
|
return [];
|