From 9c1327c9be1f42c65e4b4af56b0a0d3f206cff02 Mon Sep 17 00:00:00 2001 From: Philipp Kunz <code@philkunz.com> Date: Mon, 3 Mar 2025 05:21:52 +0000 Subject: [PATCH] feat(core): Introduced process management features using ProcessWrapper and enhanced configuration. --- changelog.md | 8 ++ package.json | 1 + pnpm-lock.yaml | 18 +++ ts/00_commitinfo_data.ts | 2 +- ts/classes.config.ts | 20 +++ ts/classes.processmonitor.ts | 133 ++++++++++++------ ts/classes.processwrapper.ts | 207 ++++++++++++++++++++++++++++ ts/classes.tspm.ts | 255 ++++++++++++++++++++++++++++++++++- ts/cli.ts | 8 ++ ts/plugins.ts | 2 + 10 files changed, 610 insertions(+), 44 deletions(-) create mode 100644 ts/classes.config.ts create mode 100644 ts/classes.processwrapper.ts diff --git a/changelog.md b/changelog.md index 8d3c765..1b3fcd8 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-03-03 - 1.4.0 - feat(core) +Introduced process management features using ProcessWrapper and enhanced configuration. + +- Added ProcessWrapper for wrapping and managing child processes. +- Refactored process monitoring logic using ProcessWrapper. +- Introduced TspmConfig for configuration handling. +- Enhanced CLI to support new process management commands like 'startAsDaemon'. + ## 2025-03-01 - 1.3.1 - fix(test) Update test script to fix type references and remove private method call diff --git a/package.json b/package.json index 5d0526f..c8fa616 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@types/node": "^22.13.8" }, "dependencies": { + "@push.rocks/npmextra": "^5.1.2", "@push.rocks/projectinfo": "^5.0.2", "@push.rocks/smartcli": "^4.0.11", "@push.rocks/smartpath": "^5.0.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f37ec3..1bfdc73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@push.rocks/npmextra': + specifier: ^5.1.2 + version: 5.1.2 '@push.rocks/projectinfo': specifier: ^5.0.2 version: 5.0.2 @@ -713,6 +716,9 @@ packages: '@push.rocks/mongodump@1.0.8': resolution: {integrity: sha512-oDufyjNBg8I50OaJvbHhc0RnRpJQ544dr9her0G6sA8JmI3hD2/amTdcPLVIX1kzYf5GsTUKeWuRaZgdNqz3ew==} + '@push.rocks/npmextra@5.1.2': + resolution: {integrity: sha512-0utZEsQSUDgFG6nGcm66Dh4DgPwqpUQcEAOtJKvubXIFRaOzQ3Yp6M8GKeL5VwxgFxWWtqp9xP8NxLEtHN9UcA==} + '@push.rocks/projectinfo@5.0.2': resolution: {integrity: sha512-zzieCal6jwR++o+fDl8gMpWkNV2cGEsbT96vCNZu/H9kr0iqRmapOiA4DFadkhOnhlDqvRr6TPaXESu2YUbI8Q==} @@ -5418,6 +5424,18 @@ snapshots: transitivePeerDependencies: - aws-crt + '@push.rocks/npmextra@5.1.2': + dependencies: + '@push.rocks/qenv': 6.1.0 + '@push.rocks/smartfile': 11.2.0 + '@push.rocks/smartjson': 5.0.20 + '@push.rocks/smartlog': 3.0.7 + '@push.rocks/smartpath': 5.0.18 + '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smartrx': 3.0.7 + '@push.rocks/taskbuffer': 3.1.7 + '@tsclass/tsclass': 4.4.0 + '@push.rocks/projectinfo@5.0.2': dependencies: '@push.rocks/smartfile': 10.0.41 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 38ee4e6..f58e36b 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@git.zone/tspm', - version: '1.3.1', + version: '1.4.0', description: 'a no fuzz process manager' } diff --git a/ts/classes.config.ts b/ts/classes.config.ts new file mode 100644 index 0000000..085822e --- /dev/null +++ b/ts/classes.config.ts @@ -0,0 +1,20 @@ +import * as plugins from './plugins.js'; + +export class TspmConfig { + public npmextraInstance = new plugins.npmextra.KeyValueStore({ + identityArg: '@git.zone__tspm', + typeArg: 'userHomeDir', + }) + + public async readKey(keyArg: string): Promise<string> { + return await this.npmextraInstance.readKey(keyArg); + } + + public async writeKey(keyArg: string, value: string): Promise<void> { + return await this.npmextraInstance.writeKey(keyArg, value); + } + + public async deleteKey(keyArg: string): Promise<void> { + return await this.npmextraInstance.deleteKey(keyArg); + } +} \ No newline at end of file diff --git a/ts/classes.processmonitor.ts b/ts/classes.processmonitor.ts index d6ca44d..c8af963 100644 --- a/ts/classes.processmonitor.ts +++ b/ts/classes.processmonitor.ts @@ -1,4 +1,5 @@ import * as plugins from './plugins.js'; +import { ProcessWrapper } from './classes.processwrapper.js'; export interface IMonitorConfig { name?: string; // Optional name to identify the instance @@ -7,13 +8,16 @@ export interface IMonitorConfig { args?: string[]; // Optional: arguments for the command memoryLimitBytes: number; // Maximum allowed memory (in bytes) for the process group monitorIntervalMs?: number; // Interval (in ms) at which memory is checked (default: 5000) + env?: NodeJS.ProcessEnv; // Optional: custom environment variables + logBufferSize?: number; // Optional: number of log lines to keep (default: 100) } export class ProcessMonitor { - private child: plugins.childProcess.ChildProcess | null = null; + private processWrapper: ProcessWrapper | null = null; private config: IMonitorConfig; private intervalId: NodeJS.Timeout | null = null; private stopped: boolean = true; // Initially stopped until start() is called + private restartCount: number = 0; constructor(config: IMonitorConfig) { this.config = config; @@ -23,59 +27,64 @@ export class ProcessMonitor { // Reset the stopped flag so that new processes can spawn. this.stopped = false; this.log(`Starting process monitor.`); - this.spawnChild(); + this.spawnProcess(); // Set the monitoring interval. const interval = this.config.monitorIntervalMs || 5000; this.intervalId = setInterval(() => { - if (this.child && this.child.pid) { - this.monitorProcessGroup(this.child.pid, this.config.memoryLimitBytes); + if (this.processWrapper && this.processWrapper.getPid()) { + this.monitorProcessGroup(this.processWrapper.getPid()!, this.config.memoryLimitBytes); } }, interval); } - private spawnChild(): void { + private spawnProcess(): void { // Don't spawn if the monitor has been stopped. if (this.stopped) return; - if (this.config.args && this.config.args.length > 0) { - this.log( - `Spawning command "${this.config.command}" with args [${this.config.args.join( - ', ' - )}] in directory: ${this.config.projectDir}` - ); - this.child = plugins.childProcess.spawn(this.config.command, this.config.args, { - cwd: this.config.projectDir, - detached: true, - stdio: 'inherit', - }); - } else { - this.log( - `Spawning command "${this.config.command}" in directory: ${this.config.projectDir}` - ); - // Use shell mode to allow a full command string. - this.child = plugins.childProcess.spawn(this.config.command, { - cwd: this.config.projectDir, - detached: true, - stdio: 'inherit', - shell: true, - }); - } + // Create a new process wrapper + this.processWrapper = new ProcessWrapper({ + name: this.config.name || 'unnamed-process', + command: this.config.command, + args: this.config.args, + cwd: this.config.projectDir, + env: this.config.env, + logBuffer: this.config.logBufferSize, + }); - this.log(`Spawned process with PID ${this.child.pid}`); - - // When the child process exits, restart it if the monitor isn't stopped. - this.child.on('exit', (code, signal) => { - this.log(`Child process exited with code ${code}, signal ${signal}.`); - if (!this.stopped) { - this.log('Restarting process...'); - this.spawnChild(); + // Set up event handlers + this.processWrapper.on('log', (log) => { + // Here we could add handlers to send logs somewhere + // For now, we just log system messages to the console + if (log.type === 'system') { + this.log(log.message); } }); + + this.processWrapper.on('exit', (code, signal) => { + this.log(`Process exited with code ${code}, signal ${signal}.`); + if (!this.stopped) { + this.log('Restarting process...'); + this.restartCount++; + this.spawnProcess(); + } + }); + + this.processWrapper.on('error', (error) => { + this.log(`Process error: ${error.message}`); + if (!this.stopped) { + this.log('Restarting process due to error...'); + this.restartCount++; + this.spawnProcess(); + } + }); + + // Start the process + this.processWrapper.start(); } /** - * Monitor the process group’s memory usage. If the total memory exceeds the limit, + * Monitor the process group's memory usage. If the total memory exceeds the limit, * kill the process group so that the 'exit' handler can restart it. */ private async monitorProcessGroup(pid: number, memoryLimit: number): Promise<void> { @@ -92,8 +101,10 @@ export class ProcessMonitor { memoryUsage )} exceeds limit of ${this.humanReadableBytes(memoryLimit)}. Restarting process.` ); - // Kill the entire process group by sending a signal to -PID. - process.kill(-pid, 'SIGKILL'); + // Stop the process wrapper, which will trigger the exit handler and restart + if (this.processWrapper) { + this.processWrapper.stop(); + } } } catch (error) { this.log('Error monitoring process group: ' + error); @@ -142,10 +153,48 @@ export class ProcessMonitor { if (this.intervalId) { clearInterval(this.intervalId); } - if (this.child && this.child.pid) { - process.kill(-this.child.pid, 'SIGKILL'); + if (this.processWrapper) { + this.processWrapper.stop(); } } + + /** + * Get the current logs from the process + */ + public getLogs(limit?: number): Array<{ timestamp: Date, type: string, message: string }> { + if (!this.processWrapper) { + return []; + } + return this.processWrapper.getLogs(limit); + } + + /** + * Get the number of times the process has been restarted + */ + public getRestartCount(): number { + return this.restartCount; + } + + /** + * Get the process ID if running + */ + public getPid(): number | null { + return this.processWrapper?.getPid() || null; + } + + /** + * Get process uptime in milliseconds + */ + public getUptime(): number { + return this.processWrapper?.getUptime() || 0; + } + + /** + * Check if the process is currently running + */ + public isRunning(): boolean { + return this.processWrapper?.isRunning() || false; + } /** * Helper method for logging messages with the instance name. @@ -154,4 +203,4 @@ export class ProcessMonitor { const prefix = this.config.name ? `[${this.config.name}] ` : ''; console.log(prefix + message); } -} +} \ No newline at end of file diff --git a/ts/classes.processwrapper.ts b/ts/classes.processwrapper.ts new file mode 100644 index 0000000..859d7d3 --- /dev/null +++ b/ts/classes.processwrapper.ts @@ -0,0 +1,207 @@ +import * as plugins from './plugins.js'; +import { EventEmitter } from 'events'; + +export interface IProcessWrapperOptions { + command: string; + args?: string[]; + cwd: string; + env?: NodeJS.ProcessEnv; + name: string; + logBuffer?: number; // Number of log lines to keep in memory (default: 100) +} + +export interface IProcessLog { + timestamp: Date; + type: 'stdout' | 'stderr' | 'system'; + message: string; +} + +export class ProcessWrapper extends EventEmitter { + private process: plugins.childProcess.ChildProcess | null = null; + private options: IProcessWrapperOptions; + private logs: IProcessLog[] = []; + private logBufferSize: number; + private startTime: Date | null = null; + + constructor(options: IProcessWrapperOptions) { + super(); + this.options = options; + this.logBufferSize = options.logBuffer || 100; + } + + /** + * Start the wrapped process + */ + public start(): void { + this.addSystemLog('Starting process...'); + + try { + if (this.options.args && this.options.args.length > 0) { + this.process = plugins.childProcess.spawn(this.options.command, this.options.args, { + cwd: this.options.cwd, + env: this.options.env || process.env, + stdio: ['ignore', 'pipe', 'pipe'], // We need to pipe stdout and stderr + }); + } else { + // Use shell mode to allow a full command string + this.process = plugins.childProcess.spawn(this.options.command, { + cwd: this.options.cwd, + env: this.options.env || process.env, + stdio: ['ignore', 'pipe', 'pipe'], // We need to pipe stdout and stderr + shell: true, + }); + } + + this.startTime = new Date(); + + // Handle process exit + this.process.on('exit', (code, signal) => { + this.addSystemLog(`Process exited with code ${code}, signal ${signal}`); + this.emit('exit', code, signal); + }); + + // Handle errors + this.process.on('error', (error) => { + this.addSystemLog(`Process error: ${error.message}`); + this.emit('error', error); + }); + + // Capture stdout + if (this.process.stdout) { + this.process.stdout.on('data', (data) => { + const lines = data.toString().split('\n'); + for (const line of lines) { + if (line.trim()) { + this.addLog('stdout', line); + } + } + }); + } + + // Capture stderr + if (this.process.stderr) { + this.process.stderr.on('data', (data) => { + const lines = data.toString().split('\n'); + for (const line of lines) { + if (line.trim()) { + this.addLog('stderr', line); + } + } + }); + } + + this.addSystemLog(`Process started with PID ${this.process.pid}`); + this.emit('start', this.process.pid); + + } catch (error) { + this.addSystemLog(`Failed to start process: ${error.message}`); + this.emit('error', error); + throw error; + } + } + + /** + * Stop the wrapped process + */ + public stop(): void { + if (!this.process) { + this.addSystemLog('No process running'); + return; + } + + this.addSystemLog('Stopping process...'); + + // First try SIGTERM for graceful shutdown + if (this.process.pid) { + try { + process.kill(this.process.pid, 'SIGTERM'); + + // Give it 5 seconds to shut down gracefully + setTimeout(() => { + if (this.process && this.process.pid) { + this.addSystemLog('Process did not exit gracefully, force killing...'); + try { + process.kill(this.process.pid, 'SIGKILL'); + } catch (error) { + // Process might have exited between checks + } + } + }, 5000); + } catch (error) { + this.addSystemLog(`Error stopping process: ${error.message}`); + } + } + } + + /** + * Get the process ID if running + */ + public getPid(): number | null { + return this.process?.pid || null; + } + + /** + * Get the current logs + */ + public getLogs(limit: number = this.logBufferSize): IProcessLog[] { + // Return the most recent logs up to the limit + return this.logs.slice(-limit); + } + + /** + * Get uptime in milliseconds + */ + public getUptime(): number { + if (!this.startTime) return 0; + return Date.now() - this.startTime.getTime(); + } + + /** + * Check if the process is currently running + */ + public isRunning(): boolean { + return this.process !== null && typeof this.process.exitCode !== 'number'; + } + + /** + * Add a log entry from stdout or stderr + */ + private addLog(type: 'stdout' | 'stderr', message: string): void { + const log: IProcessLog = { + timestamp: new Date(), + type, + message, + }; + + this.logs.push(log); + + // Trim logs if they exceed buffer size + if (this.logs.length > this.logBufferSize) { + this.logs = this.logs.slice(-this.logBufferSize); + } + + // Emit log event for potential handlers + this.emit('log', log); + } + + /** + * Add a system log entry (not from the process itself) + */ + private addSystemLog(message: string): void { + const log: IProcessLog = { + timestamp: new Date(), + type: 'system', + message, + }; + + this.logs.push(log); + + // Trim logs if they exceed buffer size + if (this.logs.length > this.logBufferSize) { + this.logs = this.logs.slice(-this.logBufferSize); + } + + // Emit log event for potential handlers + this.emit('log', log); + } +} \ No newline at end of file diff --git a/ts/classes.tspm.ts b/ts/classes.tspm.ts index 3a591d5..82fb275 100644 --- a/ts/classes.tspm.ts +++ b/ts/classes.tspm.ts @@ -1,6 +1,259 @@ import * as plugins from './plugins.js'; import * as paths from './paths.js'; +import { ProcessMonitor, type IMonitorConfig } from './classes.processmonitor.js'; +import { TspmConfig } from './classes.config.js'; + +export interface IProcessConfig extends IMonitorConfig { + id: string; // Unique identifier for the process + autorestart: boolean; // Whether to restart the process automatically on crash + watch?: boolean; // Whether to watch for file changes and restart + watchPaths?: string[]; // Paths to watch for changes +} + +export interface IProcessInfo { + id: string; + pid?: number; + status: 'online' | 'stopped' | 'errored'; + memory: number; + cpu?: number; + uptime?: number; + restarts: number; +} + +export interface IProcessLog { + timestamp: Date; + type: 'stdout' | 'stderr' | 'system'; + message: string; +} export class Tspm { - + private processes: Map<string, ProcessMonitor> = new Map(); + private processConfigs: Map<string, IProcessConfig> = new Map(); + private processInfo: Map<string, IProcessInfo> = new Map(); + private config: TspmConfig; + private configStorageKey = 'processes'; + + constructor() { + this.config = new TspmConfig(); + this.loadProcessConfigs(); + } + + /** + * Start a new process with the given configuration + */ + public async start(config: IProcessConfig): Promise<void> { + // Check if process with this id already exists + if (this.processes.has(config.id)) { + throw new Error(`Process with id '${config.id}' already exists`); + } + + // Create and store process config + this.processConfigs.set(config.id, config); + + // Initialize process info + this.processInfo.set(config.id, { + id: config.id, + status: 'stopped', + memory: 0, + restarts: 0 + }); + + // Create and start process monitor + const monitor = new ProcessMonitor({ + name: config.name || config.id, + projectDir: config.projectDir, + command: config.command, + args: config.args, + memoryLimitBytes: config.memoryLimitBytes, + monitorIntervalMs: config.monitorIntervalMs + }); + + this.processes.set(config.id, monitor); + monitor.start(); + + // Update process info + this.updateProcessInfo(config.id, { status: 'online' }); + + // Save updated configs + await this.saveProcessConfigs(); + } + + /** + * Stop a process by id + */ + public async stop(id: string): Promise<void> { + const monitor = this.processes.get(id); + if (!monitor) { + throw new Error(`Process with id '${id}' not found`); + } + + monitor.stop(); + this.updateProcessInfo(id, { status: 'stopped' }); + + // Don't remove from the maps, just mark as stopped + // This allows it to be restarted later + } + + /** + * Restart a process by id + */ + public async restart(id: string): Promise<void> { + const monitor = this.processes.get(id); + const config = this.processConfigs.get(id); + + if (!monitor || !config) { + throw new Error(`Process with id '${id}' not found`); + } + + // Stop and then start the process + monitor.stop(); + + // Create a new monitor instance + const newMonitor = new ProcessMonitor({ + name: config.name || config.id, + projectDir: config.projectDir, + command: config.command, + args: config.args, + memoryLimitBytes: config.memoryLimitBytes, + monitorIntervalMs: config.monitorIntervalMs + }); + + this.processes.set(id, newMonitor); + newMonitor.start(); + + // Update restart count + const info = this.processInfo.get(id); + if (info) { + this.updateProcessInfo(id, { + status: 'online', + restarts: info.restarts + 1 + }); + } + } + + /** + * Delete a process by id + */ + public async delete(id: string): Promise<void> { + // Stop the process if it's running + try { + await this.stop(id); + } catch (error) { + // Ignore errors if the process is not running + } + + // Remove from all maps + this.processes.delete(id); + this.processConfigs.delete(id); + this.processInfo.delete(id); + + // Save updated configs + await this.saveProcessConfigs(); + } + + /** + * Get a list of all process infos + */ + public list(): IProcessInfo[] { + return Array.from(this.processInfo.values()); + } + + /** + * Get detailed info for a specific process + */ + public describe(id: string): { config: IProcessConfig; info: IProcessInfo } | null { + const config = this.processConfigs.get(id); + const info = this.processInfo.get(id); + + if (!config || !info) { + return null; + } + + return { config, info }; + } + + /** + * Get process logs + */ + public getLogs(id: string, limit?: number): IProcessLog[] { + const monitor = this.processes.get(id); + if (!monitor) { + return []; + } + + return monitor.getLogs(limit); + } + + /** + * Start all saved processes + */ + public async startAll(): Promise<void> { + for (const [id, config] of this.processConfigs.entries()) { + if (!this.processes.has(id)) { + await this.start(config); + } + } + } + + /** + * Stop all running processes + */ + public async stopAll(): Promise<void> { + for (const id of this.processes.keys()) { + await this.stop(id); + } + } + + /** + * Restart all processes + */ + public async restartAll(): Promise<void> { + for (const id of this.processes.keys()) { + await this.restart(id); + } + } + + /** + * Update the info for a process + */ + private updateProcessInfo(id: string, update: Partial<IProcessInfo>): void { + const info = this.processInfo.get(id); + if (info) { + this.processInfo.set(id, { ...info, ...update }); + } + } + + /** + * Save all process configurations to config storage + */ + private async saveProcessConfigs(): Promise<void> { + const configs = Array.from(this.processConfigs.values()); + await this.config.writeKey(this.configStorageKey, JSON.stringify(configs)); + } + + /** + * Load process configurations from config storage + */ + private async loadProcessConfigs(): Promise<void> { + try { + const configsJson = await this.config.readKey(this.configStorageKey); + if (configsJson) { + const configs = JSON.parse(configsJson) as IProcessConfig[]; + for (const config of configs) { + this.processConfigs.set(config.id, config); + + // Initialize process info + this.processInfo.set(config.id, { + id: config.id, + status: 'stopped', + memory: 0, + restarts: 0 + }); + } + } + } catch (error) { + // If no configs found or error reading, just continue with empty configs + console.log('No saved process configurations found'); + } + } } \ No newline at end of file diff --git a/ts/cli.ts b/ts/cli.ts index 4dcd933..409e375 100644 --- a/ts/cli.ts +++ b/ts/cli.ts @@ -17,5 +17,13 @@ export const run = async () => { }) + smartcliInstance.addCommand('startAsDaemon').subscribe({ + + }) + + smartcliInstance.addCommand('stop').subscribe({ + + }) + smartcliInstance.startParse(); } \ No newline at end of file diff --git a/ts/plugins.ts b/ts/plugins.ts index afad6f7..8a7ddbf 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -8,11 +8,13 @@ export { } // @push.rocks scope +import * as npmextra from '@push.rocks/npmextra'; import * as projectinfo from '@push.rocks/projectinfo'; import * as smartpath from '@push.rocks/smartpath'; import * as smartcli from '@push.rocks/smartcli'; export { + npmextra, projectinfo, smartpath, smartcli,