bgrun 3.12.12 → 3.12.14

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,217 +0,0 @@
1
- import type { CommandOptions } from "../types";
2
- import { getProcess, removeProcessByName, retryDatabaseOperation, insertProcess } from "../db";
3
- import { isProcessRunning, terminateProcess, getHomeDir, getShellCommand, killProcessOnPort, findChildPid, getProcessPorts, waitForPortFree, psExec } from "../platform";
4
- import { error, announce } from "../logger";
5
- import { validateDirectory, parseEnvString } from "../utils";
6
- import { parseConfigFile } from "../config";
7
- import { $ } from "bun";
8
- import { sleep } from "bun";
9
- import { join } from "path";
10
- import { createMeasure } from "measure-fn";
11
-
12
- const homePath = getHomeDir();
13
- const run = createMeasure('run');
14
-
15
- export async function handleRun(options: CommandOptions) {
16
- const { command, directory, env, name, configPath, force, fetch, stdout, stderr } = options;
17
-
18
- const existingProcess = name ? getProcess(name) : null;
19
-
20
- // Auto-start unmet dependencies before starting this process
21
- if (name && existingProcess) {
22
- const { getUnmetDeps } = await import('../deps');
23
- const unmet = await getUnmetDeps(name);
24
- if (unmet.length > 0) {
25
- await run.measure(`Start ${unmet.length} dependencies for "${name}"`, async () => {
26
- for (const depName of unmet) {
27
- const depProc = getProcess(depName);
28
- if (depProc) {
29
- announce(`📦 Starting dependency "${depName}" for "${name}"`, 'Dependency');
30
- await handleRun({ action: 'run', name: depName, force: true, remoteName: '' });
31
- }
32
- }
33
- });
34
- }
35
- }
36
-
37
- if (existingProcess) {
38
- const finalDirectory = directory || existingProcess.workdir;
39
- validateDirectory(finalDirectory);
40
- $.cwd(finalDirectory);
41
-
42
- if (fetch) {
43
- if (!require('fs').existsSync(require('path').join(finalDirectory, '.git'))) {
44
- error(`Cannot --fetch: '${finalDirectory}' is not a Git repository.`);
45
- }
46
- await run.measure(`Git fetch "${name}"`, async () => {
47
- try {
48
- await $`git fetch origin`;
49
- const localHash = (await $`git rev-parse HEAD`.text()).trim();
50
- const remoteHash = (await $`git rev-parse origin/$(git rev-parse --abbrev-ref HEAD)`.text()).trim();
51
-
52
- if (localHash !== remoteHash) {
53
- await $`git pull origin $(git rev-parse --abbrev-ref HEAD)`;
54
- announce("📥 Pulled latest changes", "Git Update");
55
- }
56
- } catch (err) {
57
- error(`Failed to pull latest changes: ${err}`);
58
- }
59
- });
60
- }
61
-
62
- const isRunning = await isProcessRunning(existingProcess.pid);
63
- if (isRunning && !force) {
64
- error(`Process '${name}' is currently running. Use --force to restart.`);
65
- }
66
-
67
- // Detect ports BEFORE killing so we can clean them up
68
- let detectedPorts: number[] = [];
69
- if (isRunning) {
70
- detectedPorts = await getProcessPorts(existingProcess.pid);
71
- }
72
-
73
- if (isRunning) {
74
- await run.measure(`Terminate "${name}" (PID ${existingProcess.pid})`, async () => {
75
- await terminateProcess(existingProcess.pid);
76
- announce(`🔥 Terminated existing process '${name}'`, "Process Terminated");
77
- });
78
- }
79
-
80
- // Kill anything still on the ports the old process was using
81
- if (detectedPorts.length > 0) {
82
- await run.measure(`Port cleanup [${detectedPorts.join(', ')}]`, async () => {
83
- for (const port of detectedPorts) {
84
- await killProcessOnPort(port);
85
- }
86
- for (const port of detectedPorts) {
87
- const freed = await waitForPortFree(port, 5000);
88
- if (!freed) {
89
- await killProcessOnPort(port);
90
- await waitForPortFree(port, 3000);
91
- }
92
- }
93
- });
94
- }
95
-
96
- // Zombie sweep: kill any remaining bun processes matching this command
97
- // This catches orphaned children that survived taskkill when the parent shell exited
98
- // IMPORTANT: Exclude the current bgrun process and dashboard to avoid self-kill
99
- const cmdToMatch = existingProcess.command;
100
- if (cmdToMatch) {
101
- await run.measure('Zombie sweep', async () => {
102
- try {
103
- const cmdKeyword = cmdToMatch.split(' ')[1] || cmdToMatch;
104
- // Skip sweep if keyword is too generic (would match unrelated processes)
105
- const GENERIC_KEYWORDS = ['dev', 'run', 'start', 'serve', 'build', 'test'];
106
- if (GENERIC_KEYWORDS.includes(cmdKeyword.toLowerCase())) {
107
- return; // Too dangerous — skip zombie sweep for generic commands
108
- }
109
- const currentPid = process.pid;
110
- const result = await psExec(
111
- `Get-CimInstance Win32_Process -Filter "Name='bun.exe'" | Where-Object { $_.CommandLine -match '${cmdKeyword.replace(/'/g, "''")}' -and $_.ProcessId -ne ${currentPid} } | Select-Object -ExpandProperty ProcessId`,
112
- 3000
113
- );
114
- const zombiePids = result.split('\n')
115
- .map((l: string) => parseInt(l.trim()))
116
- .filter((n: number) => !isNaN(n) && n > 0 && n !== currentPid);
117
- for (const zPid of zombiePids) {
118
- await $`taskkill /F /T /PID ${zPid}`.nothrow().quiet();
119
- }
120
- if (zombiePids.length > 0) {
121
- announce(`🧹 Swept ${zombiePids.length} zombie process(es)`, 'Zombie Cleanup');
122
- }
123
- } catch { /* best effort */ }
124
- });
125
- }
126
-
127
- await retryDatabaseOperation(() =>
128
- removeProcessByName(name!)
129
- );
130
- } else {
131
- if (!directory || !name || !command) {
132
- error("'directory', 'name', and 'command' parameters are required for new processes.");
133
- }
134
- validateDirectory(directory!);
135
- $.cwd(directory!);
136
- }
137
-
138
- const finalCommand = command || existingProcess!.command;
139
- const finalDirectory = directory || (existingProcess?.workdir!);
140
- let finalEnv = env || (existingProcess ? parseEnvString(existingProcess.env) : {});
141
-
142
- // Default BGR_KEEP_ALIVE=true for new processes unless explicitly disabled
143
- if (!('BGR_KEEP_ALIVE' in finalEnv)) {
144
- finalEnv.BGR_KEEP_ALIVE = 'true';
145
- }
146
-
147
- let finalConfigPath: string | undefined | null;
148
- if (configPath !== undefined) {
149
- finalConfigPath = configPath;
150
- } else if (existingProcess) {
151
- finalConfigPath = existingProcess.configPath;
152
- } else {
153
- finalConfigPath = '.config.toml';
154
- }
155
-
156
- if (finalConfigPath) {
157
- const fullConfigPath = join(finalDirectory, finalConfigPath);
158
-
159
- if (await Bun.file(fullConfigPath).exists()) {
160
- const configEnv = await run.measure(`Parse config "${finalConfigPath}"`, async () => {
161
- try {
162
- return await parseConfigFile(fullConfigPath);
163
- } catch (err: any) {
164
- console.warn(`Warning: Failed to parse config file ${finalConfigPath}: ${err.message}`);
165
- return null;
166
- }
167
- });
168
- if (configEnv) {
169
- finalEnv = { ...finalEnv, ...configEnv };
170
- console.log(`Loaded config from ${finalConfigPath}`);
171
- }
172
- } else {
173
- console.log(`Config file '${finalConfigPath}' not found, continuing without it.`);
174
- }
175
- }
176
-
177
- const stdoutPath = stdout || existingProcess?.stdout_path || join(homePath, ".bgr", `${name}-out.txt`);
178
- Bun.write(stdoutPath, '');
179
- const stderrPath = stderr || existingProcess?.stderr_path || join(homePath, ".bgr", `${name}-err.txt`);
180
- Bun.write(stderrPath, '');
181
-
182
- const actualPid = await run.measure(`Spawn "${name}" → ${finalCommand}`, async () => {
183
- const newProcess = Bun.spawn(getShellCommand(finalCommand!), {
184
- env: { ...Bun.env, ...finalEnv },
185
- cwd: finalDirectory,
186
- stdout: Bun.file(stdoutPath),
187
- stderr: Bun.file(stderrPath),
188
- });
189
-
190
- newProcess.unref();
191
- // Give shell a moment to spawn child, then find PID before shell exits
192
- await sleep(100);
193
- // Find the actual child PID (shell wrapper exits immediately after spawning)
194
- const pid = await findChildPid(newProcess.pid);
195
- // Wait more for subprocess to initialize
196
- await sleep(400);
197
- return pid;
198
- }) ?? 0;
199
-
200
- await retryDatabaseOperation(() =>
201
- insertProcess({
202
- pid: actualPid,
203
- workdir: finalDirectory,
204
- command: finalCommand!,
205
- name: name!,
206
- env: Object.entries(finalEnv).map(([k, v]) => `${k}=${v}`).join(","),
207
- configPath: finalConfigPath || '',
208
- stdout_path: stdoutPath,
209
- stderr_path: stderrPath,
210
- })
211
- );
212
-
213
- announce(
214
- `${existingProcess ? '🔄 Restarted' : '🚀 Launched'} process "${name}" with PID ${actualPid}`,
215
- "Process Started"
216
- );
217
- }
@@ -1,223 +0,0 @@
1
- import type { CommandOptions } from "../types";
2
- import type { Process } from "../db";
3
- import { getProcess } from "../db";
4
- import { isProcessRunning } from "../platform";
5
- import { error, announce } from "../logger";
6
- import { tailFile } from "../utils";
7
- import { handleRun } from "./run";
8
- import * as fs from "fs";
9
- import path from "path";
10
- import chalk, { type ChalkInstance } from "chalk";
11
-
12
- export async function handleWatch(options: CommandOptions, logOptions: { showLogs: boolean; logType: 'stdout' | 'stderr' | 'both', lines?: number }) {
13
- let currentProcess: Process | null = null;
14
- let isRestarting = false;
15
- let debounceTimeout: Timer | null = null;
16
- let tailStops: (() => void)[] = [];
17
- let lastRestartPath: string | null = null; // Track if restart was due to file change
18
-
19
- const dumpLogsIfDead = async (proc: Process, reason: string) => {
20
- const isDead = !(await isProcessRunning(proc.pid));
21
- if (!isDead) return false;
22
-
23
- console.log(chalk.yellow(`💀 Process '${options.name}' died immediately after ${reason}—dumping logs:`));
24
-
25
- const readAndDump = (path: string, color: ChalkInstance, label: string) => {
26
- try {
27
- if (fs.existsSync(path)) {
28
- const content = fs.readFileSync(path, 'utf8').trim();
29
- if (content) {
30
- console.log(`${color.bold(label)}:\n${color(content)}\n`);
31
- } else {
32
- console.log(`${color(label)}: (empty)`);
33
- }
34
- }
35
- } catch (err) {
36
- console.warn(chalk.gray(`Could not read ${label} log: ${err}`));
37
- }
38
- };
39
-
40
- if (logOptions.logType === 'both' || logOptions.logType === 'stdout') {
41
- readAndDump(proc.stdout_path, chalk.white, '📄 Stdout');
42
- }
43
- if (logOptions.logType === 'both' || logOptions.logType === 'stderr') {
44
- readAndDump(proc.stderr_path, chalk.red, '📄 Stderr');
45
- }
46
-
47
- return true;
48
- };
49
-
50
- const waitForLogReady = (logPath: string, timeoutMs = 5000): Promise<void> => {
51
- return new Promise((resolve, reject) => {
52
- const checkReady = (): boolean => {
53
- try {
54
- if (fs.existsSync(logPath)) {
55
- const stat = fs.statSync(logPath);
56
- if (stat.size > 0) {
57
- return true;
58
- }
59
- }
60
- } catch {
61
- // Ignore errors during check
62
- }
63
- return false;
64
- };
65
-
66
- if (checkReady()) {
67
- resolve();
68
- return;
69
- }
70
-
71
- const dir = path.dirname(logPath); // path module needs import. 'path' var name conflict?
72
- // Need import path from 'path'
73
- const filename = path.basename(logPath);
74
- // ERROR: 'path' refers to module or var?
75
- // I need to import { dirname, basename } from "path";
76
-
77
- const watcher = fs.watch(dir, (eventType, changedFilename) => {
78
- if (changedFilename === filename && eventType === 'change') {
79
- if (checkReady()) {
80
- watcher.close();
81
- resolve();
82
- }
83
- }
84
- });
85
-
86
- setTimeout(() => {
87
- watcher.close();
88
- reject(new Error(`Log file ${logPath} did not become ready within ${timeoutMs}ms`));
89
- }, timeoutMs);
90
- });
91
- };
92
-
93
- const startTails = async (): Promise<(() => void)[]> => {
94
- const stops: (() => void)[] = [];
95
-
96
- if (!logOptions.showLogs || !currentProcess) return stops;
97
-
98
- console.log(chalk.gray("\n" + '─'.repeat(50) + "\n"));
99
-
100
- if (logOptions.logType === 'both' || logOptions.logType === 'stdout') {
101
- console.log(chalk.green.bold(`📄 Tailing stdout for ${options.name}:`));
102
- console.log(chalk.gray('═'.repeat(50)));
103
- try {
104
- await waitForLogReady(currentProcess.stdout_path);
105
- } catch (err: any) {
106
- console.warn(chalk.yellow(`⚠️ Stdout log not ready yet for ${options.name}—starting tail anyway: ${err.message}`));
107
- }
108
- const stop = tailFile(currentProcess.stdout_path, '', chalk.white, logOptions.lines);
109
- stops.push(stop);
110
- }
111
-
112
- if (logOptions.logType === 'both' || logOptions.logType === 'stderr') {
113
- console.log(chalk.red.bold(`📄 Tailing stderr for ${options.name}:`));
114
- console.log(chalk.gray('═'.repeat(50)));
115
- try {
116
- await waitForLogReady(currentProcess.stderr_path);
117
- } catch (err: any) {
118
- console.warn(chalk.yellow(`⚠️ Stderr log not ready yet for ${options.name}—starting tail anyway: ${err.message}`));
119
- }
120
- const stop = tailFile(currentProcess.stderr_path, '', chalk.red, logOptions.lines);
121
- stops.push(stop);
122
- }
123
-
124
- return stops;
125
- };
126
-
127
- const restartProcess = async (path?: string) => {
128
- if (isRestarting) return;
129
- isRestarting = true;
130
- const restartReason = path ? `restart (change in ${path})` : 'initial start';
131
- lastRestartPath = path || null;
132
-
133
- tailStops.forEach(stop => stop());
134
- tailStops = [];
135
-
136
- console.clear();
137
- announce(`🔄 Restarting process '${options.name}'... [${restartReason}]`, "Watch Mode");
138
-
139
- try {
140
- await handleRun({ ...options, force: true });
141
- currentProcess = getProcess(options.name!); // Need to ensure name is string
142
-
143
- if (!currentProcess) {
144
- error(`Failed to find process '${options.name}' after restart.`);
145
- return;
146
- }
147
-
148
- // Quick post-mortem if it died on startup
149
- const died = await dumpLogsIfDead(currentProcess, restartReason);
150
- if (died) {
151
- if (lastRestartPath) {
152
- console.log(chalk.yellow(`⚠️ Compile error on change—pausing restarts until manual fix.`));
153
- return; // Avoid loop on bad code
154
- } else {
155
- error(`Failed to start process '${options.name}'. Aborting watch mode.`);
156
- return;
157
- }
158
- }
159
-
160
- tailStops = await startTails();
161
- } catch (err) {
162
- error(`Error during restart: ${err}`);
163
- } finally {
164
- isRestarting = false;
165
- if (currentProcess) {
166
- console.log(chalk.cyan(`\n👀 Watching for file changes in: ${currentProcess.workdir}`));
167
- }
168
- }
169
- };
170
-
171
- // Initial start
172
- console.clear();
173
- announce(`🚀 Starting initial process '${options.name}' in watch mode...`, "Watch Mode");
174
- await handleRun(options);
175
- currentProcess = getProcess(options.name!);
176
-
177
- if (!currentProcess) {
178
- error(`Could not start or find process '${options.name}'. Aborting watch mode.`);
179
- return;
180
- }
181
-
182
- // Quick post-mortem if initial died
183
- const initialDied = await dumpLogsIfDead(currentProcess, 'initial start');
184
- if (initialDied) {
185
- error(`Failed to start process '${options.name}'. Aborting watch mode.`);
186
- return;
187
- }
188
-
189
- tailStops = await startTails();
190
-
191
- const workdir = currentProcess.workdir;
192
- console.log(chalk.cyan(`\n👀 Watching for file changes in: ${workdir}`));
193
-
194
- const watcher = fs.watch(workdir, { recursive: true }, (eventType, filename) => {
195
- if (filename == null) return;
196
- const fullPath = path.join(workdir, filename as string);
197
- if (fullPath.includes(".git") || fullPath.includes("node_modules")) return;
198
- if (debounceTimeout) clearTimeout(debounceTimeout);
199
- debounceTimeout = setTimeout(() => restartProcess(fullPath), 500);
200
- });
201
-
202
- const cleanup = async () => {
203
- console.log(chalk.magenta('\nSIGINT received...'));
204
- watcher.close();
205
- tailStops.forEach(stop => stop());
206
- if (debounceTimeout) clearTimeout(debounceTimeout);
207
-
208
- const procToKill = getProcess(options.name!);
209
- if (procToKill) {
210
- const isRunning = await isProcessRunning(procToKill.pid);
211
- if (isRunning) {
212
- // @note avoid "await terminateProcess(procToKill.pid)" because we can re-attach --watch mode to running process
213
- console.log(`process ${procToKill.name} (PID: ${procToKill.pid}) still running`);
214
- }
215
- }
216
- process.exit(0);
217
- };
218
-
219
- process.on('SIGINT', cleanup);
220
- process.on('SIGTERM', cleanup);
221
- }
222
-
223
-
package/src/config.ts DELETED
@@ -1,37 +0,0 @@
1
-
2
-
3
-
4
- function formatEnvKey(key: string): string {
5
- return key.toUpperCase().replace(/\./g, '_');
6
- }
7
-
8
- function flattenConfig(obj: any, prefix = ''): Record<string, string> {
9
- return Object.keys(obj).reduce((acc: Record<string, string>, key: string) => {
10
- const value = obj[key];
11
- const newPrefix = prefix ? `${prefix}.${key}` : key;
12
-
13
- if (Array.isArray(value)) {
14
- value.forEach((item, index) => {
15
- const indexedPrefix = `${newPrefix}.${index}`;
16
- if (typeof item === 'object' && item !== null) {
17
- Object.assign(acc, flattenConfig(item, indexedPrefix));
18
- } else {
19
- acc[formatEnvKey(indexedPrefix)] = String(item);
20
- }
21
- });
22
- } else if (typeof value === 'object' && value !== null) {
23
- Object.assign(acc, flattenConfig(value, newPrefix));
24
- } else {
25
- acc[formatEnvKey(newPrefix)] = String(value);
26
- }
27
- return acc;
28
- }, {});
29
- }
30
-
31
-
32
- export async function parseConfigFile(configPath: string): Promise<Record<string, string>> {
33
- // @note t suffix solves caching issue with env variables when using --watch flag
34
- const importPath = `${configPath}?t=${Date.now()}`;
35
- const parsedConfig = await import(importPath).then(m => m.default);
36
- return flattenConfig(parsedConfig);
37
- }