bgrun 3.3.1

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,223 @@
1
+ import type { CommandOptions, ProcessRecord } from "../types";
2
+ import { getProcess } from "../db";
3
+ import { isProcessRunning } from "../platform";
4
+ import { error, announce } from "../logger";
5
+ import { tailFile } from "../utils";
6
+ import { handleRun } from "./run";
7
+ import * as fs from "fs";
8
+ import { join } from "path";
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: ProcessRecord | 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: ProcessRecord, 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 = 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 ADDED
@@ -0,0 +1,37 @@
1
+ import { join } from "path";
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
+ }
package/src/db.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { Database, z } from "sqlite-zod-orm";
2
+ import { getHomeDir, ensureDir } from "./platform";
3
+ import { join } from "path";
4
+ import { sleep } from "bun";
5
+
6
+ // =============================================================================
7
+ // SCHEMA (inline โ€” single table, no need for a separate file)
8
+ // =============================================================================
9
+
10
+ export const ProcessSchema = z.object({
11
+ pid: z.number(),
12
+ workdir: z.string(),
13
+ command: z.string(),
14
+ name: z.string(),
15
+ env: z.string(),
16
+ configPath: z.string().default(''),
17
+ stdout_path: z.string(),
18
+ stderr_path: z.string(),
19
+ timestamp: z.string().default(() => new Date().toISOString()),
20
+ });
21
+
22
+ export type Process = z.infer<typeof ProcessSchema> & { id: number };
23
+
24
+ // =============================================================================
25
+ // DATABASE INITIALIZATION
26
+ // =============================================================================
27
+
28
+ const homePath = getHomeDir();
29
+ const dbName = process.env.DB_NAME ?? "bgr";
30
+ const dbPath = join(homePath, ".bgr", `${dbName}_v2.sqlite`);
31
+ ensureDir(join(homePath, ".bgr"));
32
+
33
+ export const db = new Database(dbPath, {
34
+ process: ProcessSchema,
35
+ }, {
36
+ indexes: {
37
+ process: ['name', 'timestamp', 'pid'],
38
+ },
39
+ });
40
+
41
+ // =============================================================================
42
+ // QUERY FUNCTIONS
43
+ // =============================================================================
44
+
45
+ export function getProcess(name: string) {
46
+ return db.process.select()
47
+ .where({ name })
48
+ .orderBy('timestamp', 'desc')
49
+ .limit(1)
50
+ .get() || null;
51
+ }
52
+
53
+ export function getAllProcesses() {
54
+ return db.process.select().all();
55
+ }
56
+
57
+ // =============================================================================
58
+ // MUTATION FUNCTIONS
59
+ // =============================================================================
60
+
61
+ export function insertProcess(data: {
62
+ pid: number;
63
+ workdir: string;
64
+ command: string;
65
+ name: string;
66
+ env: string;
67
+ configPath: string;
68
+ stdout_path: string;
69
+ stderr_path: string;
70
+ }) {
71
+ return db.process.insert({
72
+ ...data,
73
+ timestamp: new Date().toISOString(),
74
+ });
75
+ }
76
+
77
+ export function removeProcess(pid: number) {
78
+ const matches = db.process.select().where({ pid }).all();
79
+ for (const p of matches) {
80
+ db.process.delete(p.id);
81
+ }
82
+ }
83
+
84
+ export function removeProcessByName(name: string) {
85
+ const matches = db.process.select().where({ name }).all();
86
+ for (const p of matches) {
87
+ db.process.delete(p.id);
88
+ }
89
+ }
90
+
91
+ export function removeAllProcesses() {
92
+ const all = db.process.select().all();
93
+ for (const p of all) {
94
+ db.process.delete(p.id);
95
+ }
96
+ }
97
+
98
+ // =============================================================================
99
+ // UTILITIES
100
+ // =============================================================================
101
+
102
+ export async function retryDatabaseOperation<T>(operation: () => T, maxRetries = 5, delay = 100): Promise<T> {
103
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
104
+ try {
105
+ return operation();
106
+ } catch (err: any) {
107
+ if (err?.code === 'SQLITE_BUSY' && attempt < maxRetries) {
108
+ await sleep(delay * attempt);
109
+ continue;
110
+ }
111
+ throw err;
112
+ }
113
+ }
114
+ throw new Error('Max retries reached for database operation');
115
+ }