UNPKG

bgr

Version:

Bun: Background Runner - A lightweight process manager written in Bun

608 lines (533 loc) 18.1 kB
#!/usr/bin/env bun import { $, sleep } from "bun"; import { join } from "path"; import * as fs from "fs"; import { Database } from "bun:sqlite"; import { parseArgs } from "util"; import boxen from "boxen"; import chalk from "chalk"; import dedent from "dedent"; import { renderProcessTable, ProcessTableRow } from "./table"; interface CommandOptions { remoteName: string; command?: string; directory?: string; env?: Record<string, string>; configPath?: string; action: string; name?: string; force?: boolean; fetch?: boolean; stdout?: string; stderr?: string; dbPath?: string; } interface ProcessRecord { id: number; pid: number; workdir: string; command: string; name: string; env: string; timestamp: string; configPath?: string; stdout_path: string; stderr_path: string; } const homePath = (await $`echo $HOME`.text()).trim(); const dbName = process.env.DB_NAME ?? "bgr"; const dbPath = `${homePath}/.bgr/${dbName}.sqlite`; if (!fs.existsSync(`${homePath}/.bgr`)) { await $`mkdir -p ${homePath}/.bgr`.nothrow(); } let db = new Database(dbPath, { create: true }); db.query(` CREATE TABLE IF NOT EXISTS processes ( id INTEGER PRIMARY KEY AUTOINCREMENT, pid INTEGER, workdir TEXT, command TEXT, name TEXT UNIQUE, env TEXT, configPath TEXT, stdout_path TEXT, stderr_path TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP ) `).run(); function announce(message: string, title?: string) { console.log( boxen(chalk.white(message), { padding: 1, margin: 1, borderColor: 'green', title: title || "bgr", titleAlignment: 'center', borderStyle: 'round' }) ); } function error(message: string) { console.error( boxen(chalk.red(message), { padding: 1, margin: 1, borderColor: 'red', title: "Error", titleAlignment: 'center', borderStyle: 'double' }) ); process.exit(1); } function parseEnvString(envString: string): Record<string, string> { const env: Record<string, string> = {}; envString.split(",").forEach(pair => { const [key, value] = pair.split("="); if (key && value) env[key] = value; }); return env; } function validateDirectory(directory: string) { if (!directory || !fs.existsSync(directory) || !fs.existsSync(join(directory, ".git"))) { console.log(chalk.red("❌ Error: 'directory' must be a valid Git repository path.")); process.exit(1); } } function calculateRuntime(startTime: string): string { const start = new Date(startTime).getTime(); const now = new Date().getTime(); const diffInMinutes = Math.floor((now - start) / (1000 * 60)); return `${diffInMinutes} minutes`; } async function isProcessRunning(pid: number): Promise<boolean> { const result = await $`ps -p ${pid}`.nothrow().text(); return result.includes(`${pid}`); } async function terminateProcess(pid: number): Promise<void> { await $`kill ${pid}`.nothrow(); } function formatEnvKey(key: string): string { return key.toUpperCase().replace(/\./g, '_'); } function flattenConfig(obj: any, prefix = ''): Record<string, string> { return Object.keys(obj).reduce((acc: Record<string, string>, key: string) => { const fullKey = prefix ? `${prefix}_${key}` : key; if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { Object.assign(acc, flattenConfig(obj[key], fullKey)); } else { acc[formatEnvKey(fullKey)] = Array.isArray(obj[key]) ? obj[key].join(',') : String(obj[key]); } return acc; }, {}); } async function parseConfigFile(configPath: string): Promise<Record<string, string>> { const parsedConfig = await import(configPath).then(m => m.default); return flattenConfig(parsedConfig); } async function handleDelete(name: string) { const process = db.query(`SELECT * FROM processes WHERE name = ?`).get(name) as ProcessRecord; if (!process) { error(`No process found named '${name}'`); } const isRunning = await isProcessRunning(process.pid); if (isRunning) { await terminateProcess(process.pid); } if (fs.existsSync(process.stdout_path)) { fs.unlinkSync(process.stdout_path); } if (fs.existsSync(process.stderr_path)) { fs.unlinkSync(process.stderr_path); } db.query(`DELETE FROM processes WHERE name = ?`).run(name); announce(`Process '${name}' has been ${isRunning ? 'stopped and ' : ''}deleted`, "Process Deleted"); } async function handleClean() { const processes = db.query(`SELECT * FROM processes`).all() as ProcessRecord[]; let cleanedCount = 0; let deletedLogs = 0; for (const proc of processes) { const running = await isProcessRunning(proc.pid); if (!running) { db.query(`DELETE FROM processes WHERE pid = ?`).run(proc.pid); cleanedCount++; if (fs.existsSync(proc.stdout_path)) { fs.unlinkSync(proc.stdout_path); deletedLogs++; } if (fs.existsSync(proc.stderr_path)) { fs.unlinkSync(proc.stderr_path); deletedLogs++; } } } if (cleanedCount === 0) { announce("No stopped processes found to clean.", "Clean Complete"); } else { announce( `Cleaned ${cleanedCount} stopped ${cleanedCount === 1 ? 'process' : 'processes'} and removed ${deletedLogs} log ${deletedLogs === 1 ? 'file' : 'files'}.`, "Clean Complete" ); } } async function handleDeleteAll() { const processes = db.query(`SELECT * FROM processes`).all() as ProcessRecord[]; if (processes.length === 0) { announce("There are no processes to delete.", "Delete All"); return; } for (const proc of processes) { const running = await isProcessRunning(proc.pid); if (running) { await terminateProcess(proc.pid); } } db.query(`DELETE FROM processes`).run(); announce("All processes have been stopped and deleted.", "Delete All"); } async function showAll() { const processes = db.query(`SELECT * FROM processes`).all() as ProcessRecord[]; const tableData: ProcessTableRow[] = []; for (const process of processes) { const isRunning = await isProcessRunning(process.pid); const runtime = calculateRuntime(process.timestamp); tableData.push({ id: process.id, pid: process.pid, name: process.name, command: process.command, workdir: process.workdir, status: isRunning ? chalk.green.bold("● Running") : chalk.red.bold("○ Stopped"), runtime: runtime }); } const tableOutput = renderProcessTable(tableData, { padding: 1, borderStyle: "rounded", showHeaders: true }); console.log(tableOutput); const runningCount = tableData.filter(p => p.status.includes("Running")).length; const stoppedCount = tableData.filter(p => p.status.includes("Stopped")).length; console.log(chalk.cyan(`Total: ${tableData.length} processes (${chalk.green(`${runningCount} running`)}, ${chalk.red(`${stoppedCount} stopped`)})`)); } async function showDetails(name: string) { const process = db.query(`SELECT * FROM processes WHERE name = ? ORDER BY timestamp DESC LIMIT 1`).get(name) as ProcessRecord; if (!process) { error(`No process found named '${name}'`); } const isRunning = await isProcessRunning(process.pid); const runtime = calculateRuntime(process.timestamp); const envVars = parseEnvString(process.env); const details = ` ${chalk.bold('Process Details:')} ${chalk.gray('═'.repeat(50))} ${chalk.cyan.bold('Name:')} ${process.name} ${chalk.yellow.bold('PID:')} ${process.pid} ${chalk.bold('Status:')} ${isRunning ? chalk.green.bold("● Running") : chalk.red.bold("○ Stopped")} ${chalk.magenta.bold('Runtime:')} ${runtime} ${chalk.blue.bold('Working Directory:')} ${process.workdir} ${chalk.white.bold('Command:')} ${process.command} ${chalk.gray.bold('Config Path:')} ${process.configPath} ${chalk.green.bold('Stdout Path:')} ${process.stdout_path} ${chalk.red.bold('Stderr Path:')} ${process.stderr_path} ${chalk.bold('🔧 Environment Variables:')} ${chalk.gray('═'.repeat(50))} ${Object.entries(envVars) .map(([key, value]) => `${chalk.cyan.bold(key)} = ${chalk.yellow(value)}`) .join('\n')} `; announce(details, `Process Details: ${name}`); } async function retryDatabaseOperation<T>(operation: () => T, maxRetries = 5, delay = 100): Promise<T> { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return operation(); } catch (err: any) { if (err?.code === 'SQLITE_BUSY' && attempt < maxRetries) { await sleep(delay * attempt); continue; } throw err; } } throw new Error('Max retries reached for database operation'); } async function handleRun(options: CommandOptions) { const { command, directory, env, name, configPath, force, fetch, stdout, stderr } = options; const existingProcess = name ? db.query(`SELECT * FROM processes WHERE name = ? ORDER BY timestamp DESC LIMIT 1`).get(name) as ProcessRecord : null; if (existingProcess) { const finalDirectory = directory || existingProcess.workdir; validateDirectory(finalDirectory); $.cwd(finalDirectory); if (fetch) { try { await $`git fetch origin`; const localHash = (await $`git rev-parse HEAD`.text()).trim(); const remoteHash = (await $`git rev-parse origin/$(git rev-parse --abbrev-ref HEAD)`.text()).trim(); if (localHash !== remoteHash) { await $`git pull origin $(git rev-parse --abbrev-ref HEAD)`; announce("📥 Pulled latest changes", "Git Update"); } } catch (err) { error(`Failed to pull latest changes: ${err}`); } } const isRunning = await isProcessRunning(existingProcess.pid); if (isRunning && !force) { error(`Process '${name}' is currently running. Use --force to restart.`); } if (isRunning) { await terminateProcess(existingProcess.pid); announce(`🔥 Terminated existing process '${name}'`, "Process Terminated"); } await retryDatabaseOperation(() => db.query(`DELETE FROM processes WHERE pid = ?`).run(existingProcess.pid) ); } else { if (!directory || !name || !command) { error("'directory', 'name', and 'command' parameters are required for new processes."); } validateDirectory(directory!); $.cwd(directory); } const finalCommand = command || existingProcess!.command; const finalDirectory = directory || (existingProcess?.workdir!); let finalEnv = env || (existingProcess ? parseEnvString(existingProcess.env) : {}); let finalConfigPath: string | undefined | null; if (configPath !== undefined) { // 1. A new --config path was explicitly provided. Use it. finalConfigPath = configPath; } else if (existingProcess) { // 2. Restarting an existing process without a new --config. Use the stored path. finalConfigPath = existingProcess.configPath; } else { // 3. Starting a completely new process. Default to .config.toml. finalConfigPath = '.config.toml'; } if (finalConfigPath) { const fullConfigPath = join(finalDirectory, finalConfigPath); if (await Bun.file(fullConfigPath).exists()) { try { const newConfigEnv = await parseConfigFile(fullConfigPath); finalEnv = { ...finalEnv, ...newConfigEnv }; console.log(`Loaded config from ${finalConfigPath}`); } catch (err: any) { console.warn(`Warning: Failed to parse config file ${finalConfigPath}: ${err.message}`); } } else { // Gracefully handle missing config file without crashing. console.log(`Config file '${finalConfigPath}' not found, continuing without it.`); } } // === FIX END === const stdoutPath = stdout || join(homePath, ".bgr", `${name}-out.txt`); Bun.write(stdoutPath, ''); const stderrPath = stderr || join(homePath, ".bgr", `${name}-err.txt`); Bun.write(stderrPath, ''); const newProcess = Bun.spawn(finalCommand.split(" "), { env: { ...Bun.env, ...finalEnv }, cwd: finalDirectory, stdout: Bun.file(stdoutPath), stderr: Bun.file(stderrPath), }); newProcess.unref(); const timestamp = new Date().toISOString(); await retryDatabaseOperation(() => db.query( `INSERT INTO processes (pid, workdir, command, name, env, configPath, stdout_path, stderr_path, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( newProcess.pid, finalDirectory, finalCommand, name!, Object.entries(finalEnv).map(([k, v]) => `${k}=${v}`).join(","), finalConfigPath || '', stdoutPath, stderrPath, timestamp ) ); announce( `${existingProcess ? '🔄 Restarted' : '🚀 Launched'} process "${name}" with PID ${newProcess.pid}`, "Process Started" ); } async function hasRunningProcesses(): Promise<boolean> { const processes = db.query(`SELECT pid FROM processes`).all() as ProcessRecord[]; return processes.length > 0; } async function showHelp() { const usage = dedent` ${chalk.bold('bgr - Bun: Background Runner')} ${chalk.gray('═'.repeat(50))} ${chalk.cyan.bold('Commands:')} 1. Process Management ${chalk.gray('─'.repeat(30))} List all processes: $ bgr View process details: $ bgr <process-name> $ bgr --name <process-name> Start new process: $ bgr --name <process-name> --directory <path> --command "<command>" Restart process: $ bgr <process-name> --restart Delete process (by name): $ bgr --delete <process-name> Delete ALL processes: $ bgr --nuke Clean stopped processes: $ bgr --clean 2. Optional Parameters ${chalk.gray('─'.repeat(30))} --config <path> Config file for environment variables (default: .config.toml) --force Force restart if process is running --fetch Pull latest git changes before running --stdout <path> Custom stdout log path --stderr <path> Custom stderr log path --db <path> Custom database file path --help Show this help message --nuke Delete all processes (use with caution!) 3. Environment ${chalk.gray('─'.repeat(30))} Default database location: ~/.bgr/bgr.sqlite Default log location: ~/.bgr/<process-name>-{out|err}.txt ${chalk.bold('Examples:')} Start a Node.js application: $ bgr --name myapp --directory ~/projects/myapp --command "npm start" Restart with latest changes: $ bgr myapp --restart --fetch Use custom database: $ bgr --db ~/custom/path/mydb.sqlite Start with custom config: $ bgr --name myapp --config custom.config.toml --directory ./app `; announce(usage, "BGR Usage Guide"); } async function main() { const args = parseArgs({ args: Bun.argv, options: { remote: { type: "string", default: "origin" }, directory: { type: "string" }, command: { type: "string" }, name: { type: "string" }, config: { type: "string" }, force: { type: "boolean" }, fetch: { type: "boolean" }, stdout: { type: "string" }, stderr: { type: "string" }, help: { type: "boolean" }, restart: { type: "boolean" }, delete: { type: "boolean" }, db: { type: "string" }, nuke: { type: "boolean" }, clean: { type: "boolean" }, }, allowPositionals: true }); if (args.values.db) { const customDbDir = join(args.values.db, ".."); if (!fs.existsSync(customDbDir)) { await $`mkdir -p ${customDbDir}`.nothrow(); } db = new Database(args.values.db, { create: true }); db.query(` CREATE TABLE IF NOT EXISTS processes ( id INTEGER PRIMARY KEY AUTOINCREMENT, pid INTEGER, workdir TEXT, command TEXT, name TEXT UNIQUE, env TEXT, configPath TEXT, stdout_path TEXT, stderr_path TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP ) `).run(); } const processName = args.positionals[2]; let action: string; if (args.values.help) { action = 'help'; } else if (args.values.nuke) { action = 'delete-all'; } else if (args.values.clean) { action = 'clean'; } else if (args.values.delete) { if (processName || args.values.name) { action = 'delete'; } else { error("Please specify a process name to delete or use --nuke to delete all processes"); } } else if (args.values.restart) { action = 'run'; args.values.force = true; } else if (args.values.command) { action = 'run'; } else if (processName || args.values.name) { action = 'show-details'; } else { action = 'show-all'; } const options: CommandOptions = { remoteName: args.values.remote!, directory: args.values.directory, command: args.values.command, name: processName || args.values.name, configPath: args.values.config, action, force: args.values.force, fetch: args.values.fetch, stdout: args.values.stdout, stderr: args.values.stderr, dbPath: args.values.db, env: {} }; try { switch (action) { case 'help': await showHelp(); break; case 'show-all': if (await hasRunningProcesses()) { await showAll(); } else { announce( "No running processes found. Use the following commands to get started:", "Welcome to BGR" ); await showHelp(); } break; case 'show-details': await showDetails(options.name!); break; case 'run': await handleRun(options); break; case 'delete': await handleDelete(options.name!); break; case 'delete-all': await handleDeleteAll(); break; case 'clean': await handleClean(); break; default: error("Invalid action specified"); } } catch (err) { error(`Unexpected error: ${err}`); } } if (import.meta.path === Bun.main) { main(); }