Compare commits
10 Commits
Author | SHA1 | Date | |
---|---|---|---|
4e0944034b | |||
ca0dfa6432 | |||
b020cdcbf4 | |||
80fae0589f | |||
4d1976332b | |||
3ad8f29e1c | |||
1c06fb54b9 | |||
779593f73a | |||
5c4836fd68 | |||
2dc766fa6e |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,4 +16,8 @@ node_modules/
|
||||
dist/
|
||||
dist_*/
|
||||
|
||||
# AI
|
||||
.claude/
|
||||
.serena/
|
||||
|
||||
#------# custom
|
55
changelog.md
55
changelog.md
@@ -1,6 +1,52 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-08-25 - 1.7.0 - feat(readme)
|
||||
Add comprehensive README with detailed usage, command reference, daemon management, architecture and development instructions
|
||||
|
||||
- Expanded README from a short placeholder to a full documentation covering: Quick Start, Installation, Command Reference, Daemon Management, Monitoring & Information, Batch Operations, Architecture, Programmatic Usage, Advanced Features, Development, Debugging, Performance, and Legal information
|
||||
- Included usage examples and CLI command reference for start/stop/restart/delete/list/describe/logs and batch/daemon commands
|
||||
- Added human-friendly memory formatting and examples, process and daemon status outputs, and programmatic TypeScript usage snippet
|
||||
- Improved onboarding instructions: cloning, installing, testing, building, and running the project
|
||||
|
||||
## 2025-08-25 - 1.6.1 - fix(daemon)
|
||||
Fix smartipc integration and add daemon/ipc integration tests
|
||||
|
||||
- Replace direct smartipc server/client construction with SmartIpc.createServer/createClient and set heartbeat: false
|
||||
- Switch IPC handler registration to use onMessage and add explicit Request/Response typing for handlers
|
||||
- Update IPC client to use SmartIpc.createClient and improve daemon start/connect logic
|
||||
- Add comprehensive tests: unit tests for TspmDaemon and TspmIpcClient and full integration tests for daemon lifecycle, process management, error handling, heartbeat and resource reporting
|
||||
|
||||
## 2025-08-25 - 1.6.0 - feat(daemon)
|
||||
Add central TSPM daemon and IPC client; refactor CLI to use daemon and improve monitoring/error handling
|
||||
|
||||
- Add central daemon implementation (ts/classes.daemon.ts) to manage all processes via a single background service and Unix socket.
|
||||
- Introduce IPC client and typed IPC contracts (ts/classes.ipcclient.ts, ts/ipc.types.ts) so CLI communicates with the daemon.
|
||||
- Refactor CLI to use the daemon for commands (ts/cli.ts): start/stop/restart/delete/list/describe/logs/start-all/stop-all/restart-all and new daemon start/stop/status commands.
|
||||
- Enhance process monitoring and wrapping: ProcessMonitor and ProcessWrapper improvements (ts/classes.processmonitor.ts, ts/classes.processwrapper.ts) with better logging, memory checks, and restart behavior.
|
||||
- Improve centralized error handling and Logger behavior (ts/utils.errorhandler.ts).
|
||||
- Persist and load process configurations via TspmConfig and config storage changes (ts/classes.config.ts, ts/classes.tspm.ts).
|
||||
- Bump dependency and devDependency versions and add packageManager entry in package.json.
|
||||
- Add ts/daemon entrypoint and export daemon/ipc types from ts/index.ts; add paths for tspm runtime dir (ts/paths.ts).
|
||||
- Update tests and test tooling imports (test/test.ts) and adjust commitinfo and readme hints.
|
||||
|
||||
## 2025-03-10 - 1.5.1 - fix(core)
|
||||
|
||||
Improve error handling, logging, and test suite; update dependency versions
|
||||
|
||||
- Updated devDependencies versions in package.json (@git.zone/tsbuild, @push.rocks/tapbundle, and @push.rocks/smartdaemon)
|
||||
- Refactored error handling and enhanced logging in ProcessMonitor and ProcessWrapper modules
|
||||
- Improved test structure by adding clear module import tests and usage examples in test files
|
||||
|
||||
## 2025-03-04 - 1.5.0 - feat(cli)
|
||||
|
||||
Enhance CLI with new process management commands
|
||||
|
||||
- Added comprehensive CLI commands for process management including start, stop, restart, list, describe and logs.
|
||||
- Implemented memory string parsing for process memory limits.
|
||||
- Enhanced CLI output with formatted table listings for active processes.
|
||||
|
||||
## 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.
|
||||
@@ -9,12 +55,14 @@ Introduced process management features using ProcessWrapper and enhanced configu
|
||||
- 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
|
||||
|
||||
- Corrected type references in test script for IMonitorConfig.
|
||||
- Fixed test script to use console.log instead of private method monitor.log.
|
||||
|
||||
## 2025-03-01 - 1.3.0 - feat(cli)
|
||||
|
||||
Add CLI support with command parsing and version display
|
||||
|
||||
- Added a basic CLI interface using smartcli.
|
||||
@@ -22,6 +70,7 @@ Add CLI support with command parsing and version display
|
||||
- Integrated project version display in the CLI.
|
||||
|
||||
## 2025-03-01 - 1.2.0 - feat(core)
|
||||
|
||||
Introduce ProcessMonitor with memory management and spawning features
|
||||
|
||||
- Added ProcessMonitor class with functionality to manage process execution and memory usage.
|
||||
@@ -31,12 +80,14 @@ Introduce ProcessMonitor with memory management and spawning features
|
||||
- Updated test file to include example usage of ProcessMonitor.
|
||||
|
||||
## 2025-03-01 - 1.1.1 - fix(package)
|
||||
|
||||
Update dependencies and pnpm configuration
|
||||
|
||||
- Updated @types/node to 22.13.8
|
||||
- Updated pnpm configuration to include onlyBuiltDependencies with esbuild, mongodb-memory-server, and puppeteer
|
||||
|
||||
## 2025-03-01 - 1.1.0 - feat(core)
|
||||
|
||||
Introduce ProcessMonitor class and integrate native and external plugins
|
||||
|
||||
- Added a new ProcessMonitor class to manage and monitor child processes with memory constraints.
|
||||
@@ -44,14 +95,16 @@ Introduce ProcessMonitor class and integrate native and external plugins
|
||||
- Adjusted index and related files for improved modular structure.
|
||||
|
||||
## 2025-02-24 - 1.0.3 - fix(core)
|
||||
|
||||
Corrected description in package.json and readme.md from 'task manager' to 'process manager'.
|
||||
|
||||
- Updated the project description in package.json.
|
||||
- Aligned the description in readme.md with package.json.
|
||||
|
||||
## 2025-02-24 - 1.0.2 - fix(core)
|
||||
|
||||
Internal changes with no functional impact.
|
||||
|
||||
|
||||
## 2025-02-24 - 1.0.1 - initial release
|
||||
|
||||
Initial release with baseline functionality.
|
||||
|
@@ -15,4 +15,4 @@
|
||||
"npmGlobalTools": [],
|
||||
"npmAccessLevel": "public"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
23
package.json
23
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@git.zone/tspm",
|
||||
"version": "1.4.0",
|
||||
"version": "1.7.0",
|
||||
"private": false,
|
||||
"description": "a no fuzz process manager",
|
||||
"main": "dist_ts/index.js",
|
||||
@@ -18,19 +18,21 @@
|
||||
"tspm": "./cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.1.25",
|
||||
"@git.zone/tsbundle": "^2.0.5",
|
||||
"@git.zone/tsbuild": "^2.6.7",
|
||||
"@git.zone/tsbundle": "^2.5.1",
|
||||
"@git.zone/tsrun": "^1.2.46",
|
||||
"@git.zone/tstest": "^1.0.44",
|
||||
"@push.rocks/tapbundle": "^5.0.15",
|
||||
"@types/node": "^22.13.8"
|
||||
"@git.zone/tstest": "^2.3.5",
|
||||
"@push.rocks/tapbundle": "^6.0.3",
|
||||
"@types/node": "^22.13.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/npmextra": "^5.1.2",
|
||||
"@push.rocks/npmextra": "^5.3.3",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
"@push.rocks/smartcli": "^4.0.11",
|
||||
"@push.rocks/smartpath": "^5.0.18",
|
||||
"pidusage": "^4.0.0",
|
||||
"@push.rocks/smartdaemon": "^2.0.8",
|
||||
"@push.rocks/smartipc": "^2.0.3",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"pidusage": "^4.0.1",
|
||||
"ps-tree": "^1.2.0"
|
||||
},
|
||||
"repository": {
|
||||
@@ -60,5 +62,6 @@
|
||||
"mongodb-memory-server",
|
||||
"puppeteer"
|
||||
]
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748"
|
||||
}
|
||||
|
4813
pnpm-lock.yaml
generated
4813
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
||||
# Project Readme Hints
|
||||
|
||||
This is the initial readme hints file.
|
||||
This is the initial readme hints file.
|
||||
|
341
readme.md
341
readme.md
@@ -1,7 +1,340 @@
|
||||
# @git.zone/tspm
|
||||
# @git.zone/tspm 🚀
|
||||
|
||||
a no fuzz process manager
|
||||
**TypeScript Process Manager** - A robust, no-fuss process manager designed specifically for TypeScript and Node.js applications. Built for developers who need reliable process management without the complexity.
|
||||
|
||||
## How to create the docs
|
||||
## 🎯 What TSPM Does
|
||||
|
||||
To create docs run gitzone aidoc.
|
||||
TSPM is your production-ready process manager that handles the hard parts of running Node.js applications:
|
||||
|
||||
- **Automatic Memory Management** - Set memory limits and let TSPM handle the rest
|
||||
- **Smart Auto-Restart** - Crashed processes come back automatically (when you want them to)
|
||||
- **File Watching** - Auto-restart on file changes during development
|
||||
- **Process Groups** - Track parent and child processes together
|
||||
- **Daemon Architecture** - Survives terminal sessions with a persistent background daemon
|
||||
- **Beautiful CLI** - Clean, informative terminal output with real-time status
|
||||
- **Structured Logging** - Capture and manage stdout/stderr with intelligent buffering
|
||||
- **Zero Config** - Works out of the box, customize when you need to
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
```bash
|
||||
# Install globally
|
||||
npm install -g @git.zone/tspm
|
||||
|
||||
# Or with pnpm (recommended)
|
||||
pnpm add -g @git.zone/tspm
|
||||
|
||||
# Or use in your project
|
||||
npm install --save-dev @git.zone/tspm
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
# Start the daemon (happens automatically on first use)
|
||||
tspm daemon start
|
||||
|
||||
# Start a process
|
||||
tspm start server.js --name my-server
|
||||
|
||||
# Start with memory limit
|
||||
tspm start app.js --memory 512MB --name my-app
|
||||
|
||||
# Start with file watching (great for development)
|
||||
tspm start dev.js --watch --name dev-server
|
||||
|
||||
# List all processes
|
||||
tspm list
|
||||
|
||||
# Check process details
|
||||
tspm describe my-server
|
||||
|
||||
# View logs
|
||||
tspm logs my-server --lines 100
|
||||
|
||||
# Stop a process
|
||||
tspm stop my-server
|
||||
|
||||
# Restart a process
|
||||
tspm restart my-server
|
||||
```
|
||||
|
||||
## 📋 Command Reference
|
||||
|
||||
### Process Management
|
||||
|
||||
#### `tspm start <script> [options]`
|
||||
Start a new process with automatic monitoring and management.
|
||||
|
||||
**Options:**
|
||||
- `--name <name>` - Custom name for the process (default: script name)
|
||||
- `--memory <size>` - Memory limit (e.g., "512MB", "2GB", default: 512MB)
|
||||
- `--cwd <path>` - Working directory (default: current directory)
|
||||
- `--watch` - Enable file watching for auto-restart
|
||||
- `--watch-paths <paths>` - Comma-separated paths to watch (with --watch)
|
||||
- `--autorestart` - Auto-restart on crash (default: true)
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Simple start
|
||||
tspm start server.js
|
||||
|
||||
# Production setup with 2GB memory
|
||||
tspm start app.js --name production-api --memory 2GB
|
||||
|
||||
# Development with watching
|
||||
tspm start dev-server.js --watch --watch-paths "src,config" --name dev
|
||||
|
||||
# Custom working directory
|
||||
tspm start ../other-project/index.js --cwd ../other-project --name other
|
||||
```
|
||||
|
||||
#### `tspm stop <id>`
|
||||
Gracefully stop a running process (SIGTERM → SIGKILL after timeout).
|
||||
|
||||
```bash
|
||||
tspm stop my-server
|
||||
```
|
||||
|
||||
#### `tspm restart <id>`
|
||||
Stop and restart a process with the same configuration.
|
||||
|
||||
```bash
|
||||
tspm restart my-server
|
||||
```
|
||||
|
||||
#### `tspm delete <id>`
|
||||
Stop and remove a process from TSPM management.
|
||||
|
||||
```bash
|
||||
tspm delete old-server
|
||||
```
|
||||
|
||||
### Monitoring & Information
|
||||
|
||||
#### `tspm list`
|
||||
Display all managed processes in a beautiful table.
|
||||
|
||||
```bash
|
||||
tspm list
|
||||
|
||||
# Output:
|
||||
┌─────────┬─────────────┬───────────┬───────────┬──────────┐
|
||||
│ ID │ Name │ Status │ Memory │ Restarts │
|
||||
├─────────┼─────────────┼───────────┼───────────┼──────────┤
|
||||
│ my-app │ my-app │ online │ 245.3 MB │ 0 │
|
||||
│ worker │ worker │ online │ 128.7 MB │ 2 │
|
||||
└─────────┴─────────────┴───────────┴───────────┴──────────┘
|
||||
```
|
||||
|
||||
#### `tspm describe <id>`
|
||||
Get detailed information about a specific process.
|
||||
|
||||
```bash
|
||||
tspm describe my-server
|
||||
|
||||
# Output:
|
||||
Process Details: my-server
|
||||
────────────────────────────────────────
|
||||
Status: online
|
||||
PID: 45123
|
||||
Memory: 245.3 MB
|
||||
CPU: 2.3%
|
||||
Uptime: 3600s
|
||||
Restarts: 0
|
||||
|
||||
Configuration:
|
||||
Command: server.js
|
||||
Directory: /home/user/project
|
||||
Memory Limit: 2 GB
|
||||
Auto-restart: true
|
||||
Watch: enabled
|
||||
Watch Paths: src, config
|
||||
```
|
||||
|
||||
#### `tspm logs <id> [options]`
|
||||
View process logs (stdout and stderr).
|
||||
|
||||
**Options:**
|
||||
- `--lines <n>` - Number of lines to display (default: 50)
|
||||
|
||||
```bash
|
||||
tspm logs my-server --lines 100
|
||||
```
|
||||
|
||||
### Batch Operations
|
||||
|
||||
#### `tspm start-all`
|
||||
Start all saved processes at once.
|
||||
|
||||
```bash
|
||||
tspm start-all
|
||||
```
|
||||
|
||||
#### `tspm stop-all`
|
||||
Stop all running processes.
|
||||
|
||||
```bash
|
||||
tspm stop-all
|
||||
```
|
||||
|
||||
#### `tspm restart-all`
|
||||
Restart all running processes.
|
||||
|
||||
```bash
|
||||
tspm restart-all
|
||||
```
|
||||
|
||||
### Daemon Management
|
||||
|
||||
#### `tspm daemon start`
|
||||
Start the TSPM daemon (happens automatically on first command).
|
||||
|
||||
```bash
|
||||
tspm daemon start
|
||||
```
|
||||
|
||||
#### `tspm daemon stop`
|
||||
Stop the TSPM daemon and all managed processes.
|
||||
|
||||
```bash
|
||||
tspm daemon stop
|
||||
```
|
||||
|
||||
#### `tspm daemon status`
|
||||
Check daemon health and statistics.
|
||||
|
||||
```bash
|
||||
tspm daemon status
|
||||
|
||||
# Output:
|
||||
TSPM Daemon Status:
|
||||
────────────────────────────────────────
|
||||
Status: running
|
||||
PID: 12345
|
||||
Uptime: 86400s
|
||||
Processes: 5
|
||||
Memory: 45.2 MB
|
||||
CPU: 0.1%
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
TSPM uses a three-tier architecture for maximum reliability:
|
||||
|
||||
1. **ProcessWrapper** - Low-level process management with stream handling
|
||||
2. **ProcessMonitor** - Adds monitoring, memory limits, and auto-restart logic
|
||||
3. **Tspm Core** - High-level orchestration with configuration persistence
|
||||
|
||||
The daemon architecture ensures your processes keep running even after you close your terminal. All process communication happens through a robust IPC (Inter-Process Communication) system.
|
||||
|
||||
## 🎮 Programmatic Usage
|
||||
|
||||
TSPM can also be used as a library in your Node.js applications:
|
||||
|
||||
```typescript
|
||||
import { Tspm } from '@git.zone/tspm';
|
||||
|
||||
const manager = new Tspm();
|
||||
|
||||
// Start a process
|
||||
const processId = await manager.start({
|
||||
id: 'worker',
|
||||
name: 'Background Worker',
|
||||
command: 'node worker.js',
|
||||
projectDir: process.cwd(),
|
||||
memoryLimitBytes: 512 * 1024 * 1024, // 512MB
|
||||
autorestart: true,
|
||||
watch: false
|
||||
});
|
||||
|
||||
// Monitor process
|
||||
const info = await manager.getProcessInfo(processId);
|
||||
console.log(`Process ${info.id} is ${info.status}`);
|
||||
|
||||
// Stop process
|
||||
await manager.stop(processId);
|
||||
```
|
||||
|
||||
## 🔧 Advanced Features
|
||||
|
||||
### Memory Limit Enforcement
|
||||
TSPM tracks memory usage including all child processes spawned by your application. When a process exceeds its memory limit, it's gracefully restarted.
|
||||
|
||||
### Process Group Tracking
|
||||
Using `ps-tree`, TSPM monitors not just your main process but all child processes it spawns, ensuring complete cleanup on stop/restart.
|
||||
|
||||
### Intelligent Logging
|
||||
Logs are buffered and managed efficiently, preventing memory issues from excessive output while ensuring you don't lose important information.
|
||||
|
||||
### Graceful Shutdown
|
||||
Processes receive SIGTERM first, allowing them to clean up. After a timeout, SIGKILL ensures termination.
|
||||
|
||||
### Configuration Persistence
|
||||
Process configurations are saved, allowing you to restart all processes after a system reboot with a single command.
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://code.foss.global/git.zone/tspm.git
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
|
||||
# Build the project
|
||||
pnpm build
|
||||
|
||||
# Start development
|
||||
pnpm start
|
||||
```
|
||||
|
||||
## 🐛 Debugging
|
||||
|
||||
Enable debug mode for verbose logging:
|
||||
|
||||
```bash
|
||||
export TSPM_DEBUG=true
|
||||
tspm list
|
||||
```
|
||||
|
||||
## 📊 Performance
|
||||
|
||||
TSPM is designed to be lightweight and efficient:
|
||||
- Minimal CPU overhead (typically < 0.5%)
|
||||
- Small memory footprint (~30-50MB for the daemon)
|
||||
- Fast process startup and shutdown
|
||||
- Efficient log buffering and rotation
|
||||
|
||||
## 🤝 Why TSPM?
|
||||
|
||||
Unlike general-purpose process managers, TSPM is built specifically for the TypeScript/Node.js ecosystem:
|
||||
|
||||
- **TypeScript First** - Written in TypeScript, for TypeScript projects
|
||||
- **ESM Native** - Full support for ES modules
|
||||
- **Developer Friendly** - Beautiful CLI output and helpful error messages
|
||||
- **Production Ready** - Battle-tested memory management and error handling
|
||||
- **No Configuration Required** - Sensible defaults that just work
|
||||
- **Modern Architecture** - Async/await throughout, no callback hell
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
209
readme.plan.md
Normal file
209
readme.plan.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# TSPM Refactoring Plan: Central Daemon Architecture
|
||||
|
||||
## Problem Analysis
|
||||
|
||||
Currently, each `startAsDaemon` creates an isolated tspm instance with no coordination:
|
||||
|
||||
- Multiple daemons reading/writing same config file
|
||||
- No communication between instances
|
||||
- Inconsistent process management
|
||||
- `tspm list` shows all processes but each daemon only manages its own
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
### 1. Central Daemon Manager (`ts/classes.daemon.ts`)
|
||||
|
||||
- Single daemon instance managing ALL processes
|
||||
- Runs continuously in background
|
||||
- Uses Unix socket for IPC at `~/.tspm/tspm.sock`
|
||||
- Maintains single source of truth for process state
|
||||
|
||||
### 2. IPC Communication Layer (`ts/classes.ipc.ts`)
|
||||
|
||||
- **Framework**: Use `@push.rocks/smartipc` v2.0.1
|
||||
- **Server**: SmartIpc server in daemon using Unix Domain Socket
|
||||
- **Client**: SmartIpc client in CLI for all operations
|
||||
- **Socket Path**: `~/.tspm/tspm.sock` (Unix) or named pipe (Windows)
|
||||
- **Protocol**: Type-safe request/response with SmartIpc's built-in patterns
|
||||
- **Features**:
|
||||
- Automatic reconnection with exponential backoff
|
||||
- Heartbeat monitoring for daemon health
|
||||
- Type-safe message contracts
|
||||
- **Auto-start**: CLI starts daemon if connection fails
|
||||
|
||||
### 3. New CLI Commands
|
||||
|
||||
- `tspm enable` - Start central daemon using systemd/launchd
|
||||
- `tspm disable` - Stop and disable central daemon
|
||||
- `tspm status` - Show daemon status
|
||||
- Remove `startAsDaemon` (replaced by daemon + `tspm start`)
|
||||
|
||||
### 4. Refactored CLI (`ts/cli.ts`)
|
||||
|
||||
All commands become daemon clients:
|
||||
|
||||
```typescript
|
||||
// Before: Direct process management
|
||||
await tspm.start(config);
|
||||
|
||||
// After: Send to daemon
|
||||
await ipcClient.request('start', config);
|
||||
```
|
||||
|
||||
### 5. File Structure Changes
|
||||
|
||||
```
|
||||
ts/
|
||||
├── classes.daemon.ts # New: Central daemon server
|
||||
├── classes.ipc.ts # New: IPC client/server
|
||||
├── classes.tspm.ts # Modified: Used by daemon only
|
||||
├── cli.ts # Modified: Becomes thin client
|
||||
└── classes.daemonmanager.ts # New: Systemd/launchd integration
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Phase 1: Core Infrastructure
|
||||
|
||||
- [ ] Add `@push.rocks/smartipc` dependency (v2.0.1)
|
||||
- [ ] Create IPC message type definitions for all operations
|
||||
- [ ] Implement daemon server with SmartIpc server
|
||||
- [ ] Create IPC client wrapper for CLI
|
||||
- [ ] Add daemon lifecycle management (enable/disable)
|
||||
|
||||
### Phase 2: CLI Refactoring
|
||||
|
||||
- [ ] Convert all CLI commands to SmartIpc client requests
|
||||
- [ ] Add daemon auto-start logic with connection monitoring
|
||||
- [ ] Leverage SmartIpc's built-in reconnection and error handling
|
||||
- [ ] Implement type-safe message contracts for all commands
|
||||
|
||||
### Phase 3: Migration & Cleanup
|
||||
|
||||
- [ ] Migrate existing config to daemon-compatible format
|
||||
- [ ] Remove `startAsDaemon` command
|
||||
- [ ] Add migration guide for users
|
||||
|
||||
## Technical Details
|
||||
|
||||
### IPC Implementation with SmartIpc
|
||||
|
||||
```typescript
|
||||
// Daemon server setup
|
||||
import { SmartIpc } from '@push.rocks/smartipc';
|
||||
|
||||
const ipcServer = SmartIpc.createServer({
|
||||
id: 'tspm-daemon',
|
||||
socketPath: '~/.tspm/tspm.sock', // Unix socket
|
||||
});
|
||||
|
||||
// Message handlers with type safety
|
||||
ipcServer.onMessage<StartRequest, StartResponse>(
|
||||
'start',
|
||||
async (data, clientId) => {
|
||||
const result = await tspmManager.start(data.config);
|
||||
return { success: true, processId: result.pid };
|
||||
},
|
||||
);
|
||||
|
||||
// CLI client setup
|
||||
const ipcClient = SmartIpc.createClient({
|
||||
id: 'tspm-daemon',
|
||||
socketPath: '~/.tspm/tspm.sock',
|
||||
});
|
||||
|
||||
// Type-safe requests
|
||||
const response = await ipcClient.request<StartRequest, StartResponse>('start', {
|
||||
config: processConfig,
|
||||
});
|
||||
```
|
||||
|
||||
### Message Types
|
||||
|
||||
```typescript
|
||||
interface StartRequest {
|
||||
config: ProcessConfig;
|
||||
}
|
||||
|
||||
interface StartResponse {
|
||||
success: boolean;
|
||||
processId?: number;
|
||||
error?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Daemon State File
|
||||
|
||||
`~/.tspm/daemon.state` - PID, socket path, version
|
||||
|
||||
### Process Management
|
||||
|
||||
Daemon maintains all ProcessMonitor instances internally, CLI never directly manages processes.
|
||||
|
||||
## Key Benefits
|
||||
|
||||
### Architecture Benefits
|
||||
|
||||
- Single daemon manages all processes
|
||||
- Consistent state management
|
||||
- Efficient resource usage
|
||||
- Better process coordination
|
||||
- Proper service integration with OS
|
||||
|
||||
### SmartIpc Advantages
|
||||
|
||||
- **Cross-platform**: Unix sockets on Linux/macOS, named pipes on Windows
|
||||
- **Type-safe**: Full TypeScript support with generic message types
|
||||
- **Resilient**: Automatic reconnection with exponential backoff
|
||||
- **Observable**: Built-in metrics and heartbeat monitoring
|
||||
- **Performant**: Low-latency messaging with zero external dependencies
|
||||
- **Secure**: Connection limits and message size restrictions
|
||||
|
||||
## Backwards Compatibility
|
||||
|
||||
- Keep existing config format
|
||||
- Auto-migrate on first run
|
||||
- Provide clear upgrade instructions
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────┐ IPC ┌──────────────┐
|
||||
│ CLI │◄────────────►│ Daemon │
|
||||
│ (thin client)│ Socket │ (server) │
|
||||
└─────────────┘ └──────────────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌──────────────┐
|
||||
│ │ Tspm │
|
||||
│ │ Manager │
|
||||
│ └──────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────┐ ┌──────────────┐
|
||||
│ User │ │ProcessMonitor│
|
||||
│ Commands │ │ Instances │
|
||||
└─────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
## Migration Path
|
||||
|
||||
1. **Version 2.0.0-alpha**: Implement daemon with backwards compatibility
|
||||
2. **Version 2.0.0-beta**: Deprecate `startAsDaemon`, encourage daemon mode
|
||||
3. **Version 2.0.0**: Remove legacy code, daemon-only operation
|
||||
4. **Documentation**: Update all examples and guides
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Unix socket permissions (user-only access)
|
||||
- Validate all IPC messages
|
||||
- Rate limiting for IPC requests
|
||||
- Secure daemon shutdown mechanism
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
- Unit tests for IPC layer
|
||||
- Integration tests for daemon lifecycle
|
||||
- Migration tests from current architecture
|
||||
- Performance tests for multiple processes
|
||||
- Stress tests for IPC communication
|
107
test/test.daemon.ts
Normal file
107
test/test.daemon.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as tspm from '../ts/index.js';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import { TspmDaemon } from '../ts/classes.daemon.js';
|
||||
|
||||
// Test daemon server functionality
|
||||
tap.test('TspmDaemon creation', async () => {
|
||||
const daemon = new TspmDaemon();
|
||||
expect(daemon).toBeInstanceOf(TspmDaemon);
|
||||
});
|
||||
|
||||
tap.test('Daemon PID file management', async (tools) => {
|
||||
const testDir = path.join(process.cwd(), '.nogit');
|
||||
const testPidFile = path.join(testDir, 'test-daemon.pid');
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
|
||||
// Clean up any existing test file
|
||||
await fs.unlink(testPidFile).catch(() => {});
|
||||
|
||||
// Test writing PID file
|
||||
await fs.writeFile(testPidFile, process.pid.toString());
|
||||
const pidContent = await fs.readFile(testPidFile, 'utf-8');
|
||||
expect(parseInt(pidContent)).toEqual(process.pid);
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(testPidFile);
|
||||
});
|
||||
|
||||
tap.test('Daemon socket path generation', async () => {
|
||||
const daemon = new TspmDaemon();
|
||||
// Access private property for testing (normally wouldn't do this)
|
||||
const socketPath = (daemon as any).socketPath;
|
||||
expect(socketPath).toInclude('tspm.sock');
|
||||
});
|
||||
|
||||
tap.test('Daemon shutdown handlers', async (tools) => {
|
||||
const daemon = new TspmDaemon();
|
||||
|
||||
// Test that shutdown handlers are registered
|
||||
const sigintListeners = process.listeners('SIGINT');
|
||||
const sigtermListeners = process.listeners('SIGTERM');
|
||||
|
||||
// We expect at least one listener for each signal
|
||||
// (Note: in actual test we won't start the daemon to avoid side effects)
|
||||
expect(sigintListeners.length).toBeGreaterThanOrEqual(0);
|
||||
expect(sigtermListeners.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
tap.test('Daemon process info tracking', async () => {
|
||||
const daemon = new TspmDaemon();
|
||||
const tspmInstance = (daemon as any).tspmInstance;
|
||||
|
||||
expect(tspmInstance).toBeDefined();
|
||||
expect(tspmInstance.processes).toBeInstanceOf(Map);
|
||||
expect(tspmInstance.processConfigs).toBeInstanceOf(Map);
|
||||
expect(tspmInstance.processInfo).toBeInstanceOf(Map);
|
||||
});
|
||||
|
||||
tap.test('Daemon heartbeat monitoring setup', async (tools) => {
|
||||
const daemon = new TspmDaemon();
|
||||
|
||||
// Test heartbeat interval property exists
|
||||
const heartbeatInterval = (daemon as any).heartbeatInterval;
|
||||
expect(heartbeatInterval).toEqual(null); // Should be null before start
|
||||
});
|
||||
|
||||
tap.test('Daemon shutdown state management', async () => {
|
||||
const daemon = new TspmDaemon();
|
||||
const isShuttingDown = (daemon as any).isShuttingDown;
|
||||
|
||||
expect(isShuttingDown).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('Daemon memory usage reporting', async () => {
|
||||
const memUsage = process.memoryUsage();
|
||||
|
||||
expect(memUsage.heapUsed).toBeGreaterThan(0);
|
||||
expect(memUsage.heapTotal).toBeGreaterThan(0);
|
||||
expect(memUsage.rss).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('Daemon CPU usage calculation', async () => {
|
||||
const cpuUsage = process.cpuUsage();
|
||||
|
||||
expect(cpuUsage.user).toBeGreaterThanOrEqual(0);
|
||||
expect(cpuUsage.system).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Test conversion to seconds
|
||||
const cpuSeconds = cpuUsage.user / 1000000;
|
||||
expect(cpuSeconds).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
tap.test('Daemon uptime calculation', async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Wait a bit
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const uptime = Date.now() - startTime;
|
||||
expect(uptime).toBeGreaterThanOrEqual(100);
|
||||
expect(uptime).toBeLessThan(200);
|
||||
});
|
||||
|
||||
export default tap.start();
|
266
test/test.integration.ts
Normal file
266
test/test.integration.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as tspm from '../ts/index.js';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import { spawn } from 'child_process';
|
||||
import { tspmIpcClient } from '../ts/classes.ipcclient.js';
|
||||
|
||||
// Helper to ensure daemon is stopped before tests
|
||||
async function ensureDaemonStopped() {
|
||||
try {
|
||||
await tspmIpcClient.stopDaemon(false);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
} catch (error) {
|
||||
// Ignore errors if daemon is not running
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to clean up test files
|
||||
async function cleanupTestFiles() {
|
||||
const tspmDir = path.join(os.homedir(), '.tspm');
|
||||
const pidFile = path.join(tspmDir, 'daemon.pid');
|
||||
const socketFile = path.join(tspmDir, 'tspm.sock');
|
||||
|
||||
await fs.unlink(pidFile).catch(() => {});
|
||||
await fs.unlink(socketFile).catch(() => {});
|
||||
}
|
||||
|
||||
// Integration tests for daemon-client communication
|
||||
tap.test('Full daemon lifecycle test', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
// Ensure clean state
|
||||
await ensureDaemonStopped();
|
||||
await cleanupTestFiles();
|
||||
|
||||
// Test 1: Check daemon is not running
|
||||
let status = await tspmIpcClient.getDaemonStatus();
|
||||
expect(status).toEqual(null);
|
||||
|
||||
// Test 2: Start daemon
|
||||
console.log('Starting daemon...');
|
||||
await tspmIpcClient.connect();
|
||||
|
||||
// Give daemon time to fully initialize
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Test 3: Check daemon is running
|
||||
status = await tspmIpcClient.getDaemonStatus();
|
||||
expect(status).toBeDefined();
|
||||
expect(status?.status).toEqual('running');
|
||||
expect(status?.pid).toBeGreaterThan(0);
|
||||
expect(status?.processCount).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Test 4: Stop daemon
|
||||
console.log('Stopping daemon...');
|
||||
await tspmIpcClient.stopDaemon(true);
|
||||
|
||||
// Give daemon time to shutdown
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Test 5: Check daemon is stopped
|
||||
status = await tspmIpcClient.getDaemonStatus();
|
||||
expect(status).toEqual(null);
|
||||
|
||||
done.resolve();
|
||||
});
|
||||
|
||||
tap.test('Process management through daemon', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
// Ensure daemon is running
|
||||
await tspmIpcClient.connect();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Test 1: List processes (should be empty initially)
|
||||
let listResponse = await tspmIpcClient.request('list', {});
|
||||
expect(listResponse.processes).toBeArray();
|
||||
expect(listResponse.processes.length).toEqual(0);
|
||||
|
||||
// Test 2: Start a test process
|
||||
const testConfig: tspm.IProcessConfig = {
|
||||
id: 'test-echo',
|
||||
name: 'Test Echo Process',
|
||||
command: 'echo "Test process"',
|
||||
projectDir: process.cwd(),
|
||||
memoryLimitBytes: 50 * 1024 * 1024,
|
||||
autorestart: false,
|
||||
};
|
||||
|
||||
const startResponse = await tspmIpcClient.request('start', { config: testConfig });
|
||||
expect(startResponse.processId).toEqual('test-echo');
|
||||
expect(startResponse.status).toBeDefined();
|
||||
|
||||
// Test 3: List processes (should have one process)
|
||||
listResponse = await tspmIpcClient.request('list', {});
|
||||
expect(listResponse.processes.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const process = listResponse.processes.find(p => p.id === 'test-echo');
|
||||
expect(process).toBeDefined();
|
||||
expect(process?.id).toEqual('test-echo');
|
||||
|
||||
// Test 4: Describe the process
|
||||
const describeResponse = await tspmIpcClient.request('describe', { id: 'test-echo' });
|
||||
expect(describeResponse.processInfo).toBeDefined();
|
||||
expect(describeResponse.config).toBeDefined();
|
||||
expect(describeResponse.config.id).toEqual('test-echo');
|
||||
|
||||
// Test 5: Stop the process
|
||||
const stopResponse = await tspmIpcClient.request('stop', { id: 'test-echo' });
|
||||
expect(stopResponse.success).toEqual(true);
|
||||
expect(stopResponse.message).toInclude('stopped successfully');
|
||||
|
||||
// Test 6: Delete the process
|
||||
const deleteResponse = await tspmIpcClient.request('delete', { id: 'test-echo' });
|
||||
expect(deleteResponse.success).toEqual(true);
|
||||
|
||||
// Test 7: Verify process is gone
|
||||
listResponse = await tspmIpcClient.request('list', {});
|
||||
const deletedProcess = listResponse.processes.find(p => p.id === 'test-echo');
|
||||
expect(deletedProcess).toBeUndefined();
|
||||
|
||||
// Cleanup: stop daemon
|
||||
await tspmIpcClient.stopDaemon(true);
|
||||
|
||||
done.resolve();
|
||||
});
|
||||
|
||||
tap.test('Batch operations through daemon', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
// Ensure daemon is running
|
||||
await tspmIpcClient.connect();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Add multiple test processes
|
||||
const testConfigs: tspm.IProcessConfig[] = [
|
||||
{
|
||||
id: 'batch-test-1',
|
||||
name: 'Batch Test 1',
|
||||
command: 'echo "Process 1"',
|
||||
projectDir: process.cwd(),
|
||||
memoryLimitBytes: 50 * 1024 * 1024,
|
||||
autorestart: false,
|
||||
},
|
||||
{
|
||||
id: 'batch-test-2',
|
||||
name: 'Batch Test 2',
|
||||
command: 'echo "Process 2"',
|
||||
projectDir: process.cwd(),
|
||||
memoryLimitBytes: 50 * 1024 * 1024,
|
||||
autorestart: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Start processes
|
||||
for (const config of testConfigs) {
|
||||
await tspmIpcClient.request('start', { config });
|
||||
}
|
||||
|
||||
// Test 1: Stop all processes
|
||||
const stopAllResponse = await tspmIpcClient.request('stopAll', {});
|
||||
expect(stopAllResponse.stopped).toBeArray();
|
||||
expect(stopAllResponse.stopped.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Test 2: Start all processes
|
||||
const startAllResponse = await tspmIpcClient.request('startAll', {});
|
||||
expect(startAllResponse.started).toBeArray();
|
||||
|
||||
// Test 3: Restart all processes
|
||||
const restartAllResponse = await tspmIpcClient.request('restartAll', {});
|
||||
expect(restartAllResponse.restarted).toBeArray();
|
||||
|
||||
// Cleanup: delete all test processes
|
||||
for (const config of testConfigs) {
|
||||
await tspmIpcClient.request('delete', { id: config.id }).catch(() => {});
|
||||
}
|
||||
|
||||
// Stop daemon
|
||||
await tspmIpcClient.stopDaemon(true);
|
||||
|
||||
done.resolve();
|
||||
});
|
||||
|
||||
tap.test('Daemon error handling', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
// Ensure daemon is running
|
||||
await tspmIpcClient.connect();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Test 1: Try to stop non-existent process
|
||||
try {
|
||||
await tspmIpcClient.request('stop', { id: 'non-existent-process' });
|
||||
expect(false).toEqual(true); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error.message).toInclude('Failed to stop process');
|
||||
}
|
||||
|
||||
// Test 2: Try to describe non-existent process
|
||||
try {
|
||||
await tspmIpcClient.request('describe', { id: 'non-existent-process' });
|
||||
expect(false).toEqual(true); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error.message).toInclude('not found');
|
||||
}
|
||||
|
||||
// Test 3: Try to restart non-existent process
|
||||
try {
|
||||
await tspmIpcClient.request('restart', { id: 'non-existent-process' });
|
||||
expect(false).toEqual(true); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error.message).toInclude('Failed to restart process');
|
||||
}
|
||||
|
||||
// Stop daemon
|
||||
await tspmIpcClient.stopDaemon(true);
|
||||
|
||||
done.resolve();
|
||||
});
|
||||
|
||||
tap.test('Daemon heartbeat functionality', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
// Ensure daemon is running
|
||||
await tspmIpcClient.connect();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Test heartbeat
|
||||
const heartbeatResponse = await tspmIpcClient.request('heartbeat', {});
|
||||
expect(heartbeatResponse.timestamp).toBeGreaterThan(0);
|
||||
expect(heartbeatResponse.status).toEqual('healthy');
|
||||
|
||||
// Stop daemon
|
||||
await tspmIpcClient.stopDaemon(true);
|
||||
|
||||
done.resolve();
|
||||
});
|
||||
|
||||
tap.test('Daemon memory and CPU reporting', async (tools) => {
|
||||
const done = tools.defer();
|
||||
|
||||
// Ensure daemon is running
|
||||
await tspmIpcClient.connect();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Get daemon status
|
||||
const status = await tspmIpcClient.getDaemonStatus();
|
||||
expect(status).toBeDefined();
|
||||
expect(status?.memoryUsage).toBeGreaterThan(0);
|
||||
expect(status?.cpuUsage).toBeGreaterThanOrEqual(0);
|
||||
expect(status?.uptime).toBeGreaterThan(0);
|
||||
|
||||
// Stop daemon
|
||||
await tspmIpcClient.stopDaemon(true);
|
||||
|
||||
done.resolve();
|
||||
});
|
||||
|
||||
// Cleanup after all tests
|
||||
tap.test('Final cleanup', async () => {
|
||||
await ensureDaemonStopped();
|
||||
await cleanupTestFiles();
|
||||
});
|
||||
|
||||
export default tap.start();
|
145
test/test.ipcclient.ts
Normal file
145
test/test.ipcclient.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as tspm from '../ts/index.js';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import { TspmIpcClient } from '../ts/classes.ipcclient.js';
|
||||
import * as os from 'os';
|
||||
|
||||
// Test IPC client functionality
|
||||
tap.test('TspmIpcClient creation', async () => {
|
||||
const client = new TspmIpcClient();
|
||||
expect(client).toBeInstanceOf(TspmIpcClient);
|
||||
});
|
||||
|
||||
tap.test('IPC client socket path', async () => {
|
||||
const client = new TspmIpcClient();
|
||||
const socketPath = (client as any).socketPath;
|
||||
|
||||
expect(socketPath).toInclude('.tspm');
|
||||
expect(socketPath).toInclude('tspm.sock');
|
||||
});
|
||||
|
||||
tap.test('IPC client daemon PID file path', async () => {
|
||||
const client = new TspmIpcClient();
|
||||
const daemonPidFile = (client as any).daemonPidFile;
|
||||
|
||||
expect(daemonPidFile).toInclude('.tspm');
|
||||
expect(daemonPidFile).toInclude('daemon.pid');
|
||||
});
|
||||
|
||||
tap.test('IPC client connection state', async () => {
|
||||
const client = new TspmIpcClient();
|
||||
const isConnected = (client as any).isConnected;
|
||||
|
||||
expect(isConnected).toEqual(false); // Should be false initially
|
||||
});
|
||||
|
||||
tap.test('IPC client daemon running check - no daemon', async () => {
|
||||
const client = new TspmIpcClient();
|
||||
const tspmDir = path.join(os.homedir(), '.tspm');
|
||||
const pidFile = path.join(tspmDir, 'daemon.pid');
|
||||
|
||||
// Ensure no PID file exists for this test
|
||||
await fs.unlink(pidFile).catch(() => {});
|
||||
|
||||
const isRunning = await (client as any).isDaemonRunning();
|
||||
expect(isRunning).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('IPC client daemon running check - stale PID', async () => {
|
||||
const client = new TspmIpcClient();
|
||||
const tspmDir = path.join(os.homedir(), '.tspm');
|
||||
const pidFile = path.join(tspmDir, 'daemon.pid');
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
await fs.mkdir(tspmDir, { recursive: true });
|
||||
|
||||
// Write a fake PID that doesn't exist
|
||||
await fs.writeFile(pidFile, '99999999');
|
||||
|
||||
const isRunning = await (client as any).isDaemonRunning();
|
||||
expect(isRunning).toEqual(false);
|
||||
|
||||
// Clean up - the stale PID should be removed
|
||||
const fileExists = await fs.access(pidFile).then(() => true).catch(() => false);
|
||||
expect(fileExists).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('IPC client daemon running check - current process', async () => {
|
||||
const client = new TspmIpcClient();
|
||||
const tspmDir = path.join(os.homedir(), '.tspm');
|
||||
const pidFile = path.join(tspmDir, 'daemon.pid');
|
||||
const socketFile = path.join(tspmDir, 'tspm.sock');
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
await fs.mkdir(tspmDir, { recursive: true });
|
||||
|
||||
// Write current process PID (simulating daemon is this process)
|
||||
await fs.writeFile(pidFile, process.pid.toString());
|
||||
|
||||
// Create a fake socket file
|
||||
await fs.writeFile(socketFile, '');
|
||||
|
||||
const isRunning = await (client as any).isDaemonRunning();
|
||||
expect(isRunning).toEqual(true);
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(pidFile).catch(() => {});
|
||||
await fs.unlink(socketFile).catch(() => {});
|
||||
});
|
||||
|
||||
tap.test('IPC client singleton instance', async () => {
|
||||
// Import the singleton
|
||||
const { tspmIpcClient } = await import('../ts/classes.ipcclient.js');
|
||||
|
||||
expect(tspmIpcClient).toBeInstanceOf(TspmIpcClient);
|
||||
|
||||
// Test that it's the same instance
|
||||
const { tspmIpcClient: secondImport } = await import('../ts/classes.ipcclient.js');
|
||||
expect(tspmIpcClient).toBe(secondImport);
|
||||
});
|
||||
|
||||
tap.test('IPC client request method type safety', async () => {
|
||||
const client = new TspmIpcClient();
|
||||
|
||||
// Test that request method exists
|
||||
expect(client.request).toBeInstanceOf(Function);
|
||||
expect(client.connect).toBeInstanceOf(Function);
|
||||
expect(client.disconnect).toBeInstanceOf(Function);
|
||||
expect(client.stopDaemon).toBeInstanceOf(Function);
|
||||
expect(client.getDaemonStatus).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
tap.test('IPC client error message formatting', async () => {
|
||||
const errorMessage = 'Could not connect to TSPM daemon. Please try running "tspm daemon start" manually.';
|
||||
expect(errorMessage).toInclude('tspm daemon start');
|
||||
});
|
||||
|
||||
tap.test('IPC client reconnection logic', async () => {
|
||||
const client = new TspmIpcClient();
|
||||
|
||||
// Test reconnection error conditions
|
||||
const econnrefusedError = new Error('ECONNREFUSED');
|
||||
expect(econnrefusedError.message).toInclude('ECONNREFUSED');
|
||||
|
||||
const enoentError = new Error('ENOENT');
|
||||
expect(enoentError.message).toInclude('ENOENT');
|
||||
});
|
||||
|
||||
tap.test('IPC client daemon start timeout', async () => {
|
||||
const maxWaitTime = 10000; // 10 seconds
|
||||
const checkInterval = 500; // 500ms
|
||||
|
||||
const maxChecks = maxWaitTime / checkInterval;
|
||||
expect(maxChecks).toEqual(20);
|
||||
});
|
||||
|
||||
tap.test('IPC client daemon stop timeout', async () => {
|
||||
const maxWaitTime = 15000; // 15 seconds
|
||||
const checkInterval = 500; // 500ms
|
||||
|
||||
const maxChecks = maxWaitTime / checkInterval;
|
||||
expect(maxChecks).toEqual(30);
|
||||
});
|
||||
|
||||
export default tap.start();
|
135
test/test.ts
135
test/test.ts
@@ -1,27 +1,124 @@
|
||||
import { expect, expectAsync, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as tspm from '../ts/index.js';
|
||||
import { join } from 'path';
|
||||
|
||||
tap.test('first test', async () => {
|
||||
console.log(tspm);
|
||||
// Basic module import test
|
||||
tap.test('module import test', async () => {
|
||||
console.log('Imported modules:', Object.keys(tspm));
|
||||
expect(tspm.ProcessMonitor).toBeTypeOf('function');
|
||||
expect(tspm.Tspm).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
// ProcessMonitor test
|
||||
tap.test('ProcessMonitor test', async () => {
|
||||
const config: tspm.IMonitorConfig = {
|
||||
name: 'Test Monitor',
|
||||
projectDir: process.cwd(),
|
||||
command: 'echo "Test process running"',
|
||||
memoryLimitBytes: 50 * 1024 * 1024, // 50MB
|
||||
monitorIntervalMs: 1000,
|
||||
};
|
||||
|
||||
const monitor = new tspm.ProcessMonitor(config);
|
||||
|
||||
// Test monitor creation
|
||||
expect(monitor).toBeInstanceOf(tspm.ProcessMonitor);
|
||||
|
||||
// We won't actually start it in tests to avoid side effects
|
||||
// but we can test the API
|
||||
expect(monitor.start).toBeInstanceOf('function');
|
||||
expect(monitor.stop).toBeInstanceOf('function');
|
||||
expect(monitor.getLogs).toBeInstanceOf('function');
|
||||
});
|
||||
|
||||
// Tspm class test
|
||||
tap.test('Tspm class test', async () => {
|
||||
const tspmInstance = new tspm.Tspm();
|
||||
|
||||
expect(tspmInstance).toBeInstanceOf(tspm.Tspm);
|
||||
expect(tspmInstance.start).toBeInstanceOf('function');
|
||||
expect(tspmInstance.stop).toBeInstanceOf('function');
|
||||
expect(tspmInstance.restart).toBeInstanceOf('function');
|
||||
expect(tspmInstance.list).toBeInstanceOf('function');
|
||||
expect(tspmInstance.describe).toBeInstanceOf('function');
|
||||
expect(tspmInstance.getLogs).toBeInstanceOf('function');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
|
||||
// Example usage:
|
||||
const config: tspm.IMonitorConfig = {
|
||||
name: 'Project XYZ Monitor', // Identifier for the instance
|
||||
projectDir: '/path/to/your/project', // Set the project directory here
|
||||
command: 'npm run xyz', // Full command string (no need for args)
|
||||
memoryLimitBytes: 500 * 1024 * 1024, // 500 MB memory limit
|
||||
monitorIntervalMs: 5000, // Check memory usage every 5 seconds
|
||||
};
|
||||
// ====================================================
|
||||
// Example usage (this part is not executed in tests)
|
||||
// ====================================================
|
||||
|
||||
const monitor = new tspm.ProcessMonitor(config);
|
||||
monitor.start();
|
||||
// Example 1: Using ProcessMonitor directly
|
||||
function exampleUsingProcessMonitor() {
|
||||
const config: tspm.IMonitorConfig = {
|
||||
name: 'Project XYZ Monitor',
|
||||
projectDir: '/path/to/your/project',
|
||||
command: 'npm run xyz',
|
||||
memoryLimitBytes: 500 * 1024 * 1024, // 500 MB memory limit
|
||||
monitorIntervalMs: 5000, // Check memory usage every 5 seconds
|
||||
logBufferSize: 200, // Keep last 200 log lines
|
||||
};
|
||||
|
||||
// Ensure that on process exit (e.g. Ctrl+C) we clean up the child process and prevent respawns.
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Received SIGINT, stopping monitor...');
|
||||
monitor.stop();
|
||||
process.exit();
|
||||
});
|
||||
const monitor = new tspm.ProcessMonitor(config);
|
||||
monitor.start();
|
||||
|
||||
// Ensure that on process exit (e.g. Ctrl+C) we clean up the child process and prevent respawns.
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Received SIGINT, stopping monitor...');
|
||||
monitor.stop();
|
||||
process.exit();
|
||||
});
|
||||
|
||||
// Get logs example
|
||||
setTimeout(() => {
|
||||
const logs = monitor.getLogs(10); // Get last 10 log lines
|
||||
console.log('Latest logs:', logs);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Example 2: Using Tspm (higher-level process manager)
|
||||
async function exampleUsingTspm() {
|
||||
const tspmInstance = new tspm.Tspm();
|
||||
|
||||
// Start a process
|
||||
await tspmInstance.start({
|
||||
id: 'web-server',
|
||||
name: 'Web Server',
|
||||
projectDir: '/path/to/web/project',
|
||||
command: 'npm run serve',
|
||||
memoryLimitBytes: 300 * 1024 * 1024, // 300 MB
|
||||
autorestart: true,
|
||||
watch: true,
|
||||
monitorIntervalMs: 10000,
|
||||
});
|
||||
|
||||
// Start another process
|
||||
await tspmInstance.start({
|
||||
id: 'api-server',
|
||||
name: 'API Server',
|
||||
projectDir: '/path/to/api/project',
|
||||
command: 'npm run api',
|
||||
memoryLimitBytes: 400 * 1024 * 1024, // 400 MB
|
||||
autorestart: true,
|
||||
});
|
||||
|
||||
// List all processes
|
||||
const processes = tspmInstance.list();
|
||||
console.log('Running processes:', processes);
|
||||
|
||||
// Get logs from a process
|
||||
const logs = tspmInstance.getLogs('web-server', 20);
|
||||
console.log('Web server logs:', logs);
|
||||
|
||||
// Stop a process
|
||||
await tspmInstance.stop('api-server');
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Shutting down all processes...');
|
||||
await tspmInstance.stopAll();
|
||||
process.exit();
|
||||
});
|
||||
}
|
||||
|
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@git.zone/tspm',
|
||||
version: '1.4.0',
|
||||
version: '1.7.0',
|
||||
description: 'a no fuzz process manager'
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@ 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);
|
||||
@@ -17,4 +17,4 @@ export class TspmConfig {
|
||||
public async deleteKey(keyArg: string): Promise<void> {
|
||||
return await this.npmextraInstance.deleteKey(keyArg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
416
ts/classes.daemon.ts
Normal file
416
ts/classes.daemon.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as paths from './paths.js';
|
||||
import { Tspm } from './classes.tspm.js';
|
||||
import type {
|
||||
IpcMethodMap,
|
||||
RequestForMethod,
|
||||
ResponseForMethod,
|
||||
DaemonStatusResponse,
|
||||
HeartbeatResponse,
|
||||
} from './ipc.types.js';
|
||||
|
||||
/**
|
||||
* Central daemon server that manages all TSPM processes
|
||||
*/
|
||||
export class TspmDaemon {
|
||||
private tspmInstance: Tspm;
|
||||
private ipcServer: plugins.smartipc.IpcServer;
|
||||
private startTime: number;
|
||||
private isShuttingDown: boolean = false;
|
||||
private socketPath: string;
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
private daemonPidFile: string;
|
||||
|
||||
constructor() {
|
||||
this.tspmInstance = new Tspm();
|
||||
this.socketPath = plugins.path.join(paths.tspmDir, 'tspm.sock');
|
||||
this.daemonPidFile = plugins.path.join(paths.tspmDir, 'daemon.pid');
|
||||
this.startTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the daemon server
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
console.log('Starting TSPM daemon...');
|
||||
|
||||
// Check if another daemon is already running
|
||||
if (await this.isDaemonRunning()) {
|
||||
throw new Error('Another TSPM daemon instance is already running');
|
||||
}
|
||||
|
||||
// Initialize IPC server
|
||||
this.ipcServer = plugins.smartipc.SmartIpc.createServer({
|
||||
id: 'tspm-daemon',
|
||||
socketPath: this.socketPath,
|
||||
heartbeat: false, // Disable heartbeat for now
|
||||
});
|
||||
|
||||
// Register message handlers
|
||||
this.registerHandlers();
|
||||
|
||||
// Start the IPC server
|
||||
await this.ipcServer.start();
|
||||
|
||||
// Write PID file
|
||||
await this.writePidFile();
|
||||
|
||||
// Start heartbeat monitoring
|
||||
this.startHeartbeatMonitoring();
|
||||
|
||||
// Load existing process configurations
|
||||
await this.tspmInstance.loadProcessConfigs();
|
||||
|
||||
// Set up graceful shutdown handlers
|
||||
this.setupShutdownHandlers();
|
||||
|
||||
console.log(`TSPM daemon started successfully on ${this.socketPath}`);
|
||||
console.log(`PID: ${process.pid}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all IPC message handlers
|
||||
*/
|
||||
private registerHandlers(): void {
|
||||
// Process management handlers
|
||||
this.ipcServer.onMessage(
|
||||
'start',
|
||||
async (request: RequestForMethod<'start'>) => {
|
||||
try {
|
||||
await this.tspmInstance.start(request.config);
|
||||
const processInfo = this.tspmInstance.processInfo.get(
|
||||
request.config.id,
|
||||
);
|
||||
return {
|
||||
processId: request.config.id,
|
||||
pid: processInfo?.pid,
|
||||
status: processInfo?.status || 'stopped',
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to start process: ${error.message}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.ipcServer.onMessage(
|
||||
'stop',
|
||||
async (request: RequestForMethod<'stop'>) => {
|
||||
try {
|
||||
await this.tspmInstance.stop(request.id);
|
||||
return {
|
||||
success: true,
|
||||
message: `Process ${request.id} stopped successfully`,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to stop process: ${error.message}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.ipcServer.onMessage('restart', async (request: RequestForMethod<'restart'>) => {
|
||||
try {
|
||||
await this.tspmInstance.restart(request.id);
|
||||
const processInfo = this.tspmInstance.processInfo.get(request.id);
|
||||
return {
|
||||
processId: request.id,
|
||||
pid: processInfo?.pid,
|
||||
status: processInfo?.status || 'stopped',
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to restart process: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.ipcServer.onMessage(
|
||||
'delete',
|
||||
async (request: RequestForMethod<'delete'>) => {
|
||||
try {
|
||||
await this.tspmInstance.delete(request.id);
|
||||
return {
|
||||
success: true,
|
||||
message: `Process ${request.id} deleted successfully`,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to delete process: ${error.message}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Query handlers
|
||||
this.ipcServer.onMessage(
|
||||
'list',
|
||||
async (request: RequestForMethod<'list'>) => {
|
||||
const processes = await this.tspmInstance.list();
|
||||
return { processes };
|
||||
},
|
||||
);
|
||||
|
||||
this.ipcServer.onMessage('describe', async (request: RequestForMethod<'describe'>) => {
|
||||
const processInfo = await this.tspmInstance.describe(request.id);
|
||||
const config = this.tspmInstance.processConfigs.get(request.id);
|
||||
|
||||
if (!processInfo || !config) {
|
||||
throw new Error(`Process ${request.id} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
processInfo,
|
||||
config,
|
||||
};
|
||||
});
|
||||
|
||||
this.ipcServer.onMessage('getLogs', async (request: RequestForMethod<'getLogs'>) => {
|
||||
const logs = await this.tspmInstance.getLogs(request.id);
|
||||
return { logs };
|
||||
});
|
||||
|
||||
// Batch operations handlers
|
||||
this.ipcServer.onMessage('startAll', async (request: RequestForMethod<'startAll'>) => {
|
||||
const started: string[] = [];
|
||||
const failed: Array<{ id: string; error: string }> = [];
|
||||
|
||||
await this.tspmInstance.startAll();
|
||||
|
||||
// Get status of all processes
|
||||
for (const [id, processInfo] of this.tspmInstance.processInfo) {
|
||||
if (processInfo.status === 'online') {
|
||||
started.push(id);
|
||||
} else {
|
||||
failed.push({ id, error: 'Failed to start' });
|
||||
}
|
||||
}
|
||||
|
||||
return { started, failed };
|
||||
});
|
||||
|
||||
this.ipcServer.onMessage('stopAll', async (request: RequestForMethod<'stopAll'>) => {
|
||||
const stopped: string[] = [];
|
||||
const failed: Array<{ id: string; error: string }> = [];
|
||||
|
||||
await this.tspmInstance.stopAll();
|
||||
|
||||
// Get status of all processes
|
||||
for (const [id, processInfo] of this.tspmInstance.processInfo) {
|
||||
if (processInfo.status === 'stopped') {
|
||||
stopped.push(id);
|
||||
} else {
|
||||
failed.push({ id, error: 'Failed to stop' });
|
||||
}
|
||||
}
|
||||
|
||||
return { stopped, failed };
|
||||
});
|
||||
|
||||
this.ipcServer.onMessage('restartAll', async (request: RequestForMethod<'restartAll'>) => {
|
||||
const restarted: string[] = [];
|
||||
const failed: Array<{ id: string; error: string }> = [];
|
||||
|
||||
await this.tspmInstance.restartAll();
|
||||
|
||||
// Get status of all processes
|
||||
for (const [id, processInfo] of this.tspmInstance.processInfo) {
|
||||
if (processInfo.status === 'online') {
|
||||
restarted.push(id);
|
||||
} else {
|
||||
failed.push({ id, error: 'Failed to restart' });
|
||||
}
|
||||
}
|
||||
|
||||
return { restarted, failed };
|
||||
});
|
||||
|
||||
// Daemon management handlers
|
||||
this.ipcServer.onMessage('daemon:status', async (request: RequestForMethod<'daemon:status'>) => {
|
||||
const memUsage = process.memoryUsage();
|
||||
return {
|
||||
status: 'running',
|
||||
pid: process.pid,
|
||||
uptime: Date.now() - this.startTime,
|
||||
processCount: this.tspmInstance.processes.size,
|
||||
memoryUsage: memUsage.heapUsed,
|
||||
cpuUsage: process.cpuUsage().user / 1000000, // Convert to seconds
|
||||
};
|
||||
});
|
||||
|
||||
this.ipcServer.onMessage('daemon:shutdown', async (request: RequestForMethod<'daemon:shutdown'>) => {
|
||||
if (this.isShuttingDown) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Daemon is already shutting down',
|
||||
};
|
||||
}
|
||||
|
||||
// Schedule shutdown
|
||||
const graceful = request.graceful !== false;
|
||||
const timeout = request.timeout || 10000;
|
||||
|
||||
if (graceful) {
|
||||
setTimeout(() => this.shutdown(true), 100);
|
||||
} else {
|
||||
setTimeout(() => this.shutdown(false), 100);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Daemon will shutdown ${graceful ? 'gracefully' : 'immediately'} in ${timeout}ms`,
|
||||
};
|
||||
});
|
||||
|
||||
// Heartbeat handler
|
||||
this.ipcServer.onMessage('heartbeat', async (request: RequestForMethod<'heartbeat'>) => {
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
status: this.isShuttingDown ? 'degraded' : 'healthy',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat monitoring
|
||||
*/
|
||||
private startHeartbeatMonitoring(): void {
|
||||
// Send heartbeat every 30 seconds
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
// This is where we could implement health checks
|
||||
// For now, just log that the daemon is alive
|
||||
const uptime = Math.floor((Date.now() - this.startTime) / 1000);
|
||||
console.log(
|
||||
`[Heartbeat] Daemon alive - Uptime: ${uptime}s, Processes: ${this.tspmInstance.processes.size}`,
|
||||
);
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up graceful shutdown handlers
|
||||
*/
|
||||
private setupShutdownHandlers(): void {
|
||||
const shutdownHandler = async (signal: string) => {
|
||||
console.log(`\nReceived ${signal}, initiating graceful shutdown...`);
|
||||
await this.shutdown(true);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => shutdownHandler('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdownHandler('SIGINT'));
|
||||
process.on('SIGHUP', () => shutdownHandler('SIGHUP'));
|
||||
|
||||
// Handle uncaught errors
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Uncaught exception:', error);
|
||||
this.shutdown(false);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('Unhandled rejection at:', promise, 'reason:', reason);
|
||||
// Don't exit on unhandled rejection, just log it
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the daemon
|
||||
*/
|
||||
public async shutdown(graceful: boolean = true): Promise<void> {
|
||||
if (this.isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isShuttingDown = true;
|
||||
console.log('Shutting down TSPM daemon...');
|
||||
|
||||
// Clear heartbeat interval
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
}
|
||||
|
||||
if (graceful) {
|
||||
// Stop all processes gracefully
|
||||
try {
|
||||
console.log('Stopping all managed processes...');
|
||||
await this.tspmInstance.stopAll();
|
||||
} catch (error) {
|
||||
console.error('Error stopping processes:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop IPC server
|
||||
if (this.ipcServer) {
|
||||
try {
|
||||
await this.ipcServer.stop();
|
||||
} catch (error) {
|
||||
console.error('Error stopping IPC server:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove PID file
|
||||
await this.removePidFile();
|
||||
|
||||
// Remove socket file if it exists
|
||||
try {
|
||||
const fs = await import('fs');
|
||||
await fs.promises.unlink(this.socketPath).catch(() => {});
|
||||
} catch (error) {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
console.log('TSPM daemon shutdown complete');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if another daemon instance is running
|
||||
*/
|
||||
private async isDaemonRunning(): Promise<boolean> {
|
||||
try {
|
||||
const fs = await import('fs');
|
||||
const pidContent = await fs.promises.readFile(
|
||||
this.daemonPidFile,
|
||||
'utf-8',
|
||||
);
|
||||
const pid = parseInt(pidContent.trim(), 10);
|
||||
|
||||
// Check if process is running
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true; // Process exists
|
||||
} catch {
|
||||
// Process doesn't exist, clean up stale PID file
|
||||
await this.removePidFile();
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// PID file doesn't exist
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the daemon PID to a file
|
||||
*/
|
||||
private async writePidFile(): Promise<void> {
|
||||
const fs = await import('fs');
|
||||
await fs.promises.writeFile(this.daemonPidFile, process.pid.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the daemon PID file
|
||||
*/
|
||||
private async removePidFile(): Promise<void> {
|
||||
try {
|
||||
const fs = await import('fs');
|
||||
await fs.promises.unlink(this.daemonPidFile);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for the daemon
|
||||
*/
|
||||
export const startDaemon = async (): Promise<void> => {
|
||||
const daemon = new TspmDaemon();
|
||||
await daemon.start();
|
||||
|
||||
// Keep the process alive
|
||||
await new Promise(() => {});
|
||||
};
|
264
ts/classes.ipcclient.ts
Normal file
264
ts/classes.ipcclient.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as paths from './paths.js';
|
||||
import { spawn } from 'child_process';
|
||||
import type {
|
||||
IpcMethodMap,
|
||||
RequestForMethod,
|
||||
ResponseForMethod,
|
||||
} from './ipc.types.js';
|
||||
|
||||
/**
|
||||
* IPC client for communicating with the TSPM daemon
|
||||
*/
|
||||
export class TspmIpcClient {
|
||||
private ipcClient: plugins.smartipc.IpcClient | null = null;
|
||||
private socketPath: string;
|
||||
private daemonPidFile: string;
|
||||
private isConnected: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.socketPath = plugins.path.join(paths.tspmDir, 'tspm.sock');
|
||||
this.daemonPidFile = plugins.path.join(paths.tspmDir, 'daemon.pid');
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the daemon, starting it if necessary
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
// Check if already connected
|
||||
if (this.isConnected && this.ipcClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if daemon is running
|
||||
const daemonRunning = await this.isDaemonRunning();
|
||||
|
||||
if (!daemonRunning) {
|
||||
console.log('Daemon not running, starting it...');
|
||||
await this.startDaemon();
|
||||
// Wait a bit for daemon to initialize
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// Create IPC client
|
||||
this.ipcClient = plugins.smartipc.SmartIpc.createClient({
|
||||
id: 'tspm-cli',
|
||||
socketPath: this.socketPath,
|
||||
heartbeat: false, // Disable heartbeat for now
|
||||
});
|
||||
|
||||
// Connect to the daemon
|
||||
try {
|
||||
await this.ipcClient.connect();
|
||||
this.isConnected = true;
|
||||
console.log('Connected to TSPM daemon');
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to daemon:', error);
|
||||
throw new Error(
|
||||
'Could not connect to TSPM daemon. Please try running "tspm daemon start" manually.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the daemon
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
if (this.ipcClient) {
|
||||
await this.ipcClient.disconnect();
|
||||
this.ipcClient = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to the daemon
|
||||
*/
|
||||
public async request<M extends keyof IpcMethodMap>(
|
||||
method: M,
|
||||
params: RequestForMethod<M>,
|
||||
): Promise<ResponseForMethod<M>> {
|
||||
if (!this.isConnected || !this.ipcClient) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.ipcClient!.request<
|
||||
RequestForMethod<M>,
|
||||
ResponseForMethod<M>
|
||||
>(method, params);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Handle connection errors by trying to reconnect once
|
||||
if (
|
||||
error.message?.includes('ECONNREFUSED') ||
|
||||
error.message?.includes('ENOENT')
|
||||
) {
|
||||
console.log('Connection lost, attempting to reconnect...');
|
||||
this.isConnected = false;
|
||||
await this.connect();
|
||||
|
||||
// Retry the request
|
||||
return await this.ipcClient!.request<
|
||||
RequestForMethod<M>,
|
||||
ResponseForMethod<M>
|
||||
>(method, params);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the daemon is running
|
||||
*/
|
||||
private async isDaemonRunning(): Promise<boolean> {
|
||||
try {
|
||||
const fs = await import('fs');
|
||||
|
||||
// Check if PID file exists
|
||||
try {
|
||||
const pidContent = await fs.promises.readFile(
|
||||
this.daemonPidFile,
|
||||
'utf-8',
|
||||
);
|
||||
const pid = parseInt(pidContent.trim(), 10);
|
||||
|
||||
// Check if process is running
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
|
||||
// Also check if socket exists and is accessible
|
||||
try {
|
||||
await fs.promises.access(this.socketPath);
|
||||
return true;
|
||||
} catch {
|
||||
// Socket doesn't exist, daemon might be starting
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// Process doesn't exist, clean up stale PID file
|
||||
await fs.promises.unlink(this.daemonPidFile).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// PID file doesn't exist
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the daemon process
|
||||
*/
|
||||
private async startDaemon(): Promise<void> {
|
||||
const daemonScript = plugins.path.join(
|
||||
paths.packageDir,
|
||||
'dist_ts',
|
||||
'daemon.js',
|
||||
);
|
||||
|
||||
// Spawn the daemon as a detached process
|
||||
const daemonProcess = spawn(process.execPath, [daemonScript], {
|
||||
detached: true,
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
env: {
|
||||
...process.env,
|
||||
TSPM_DAEMON_MODE: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
// Unref the process so the parent can exit
|
||||
daemonProcess.unref();
|
||||
|
||||
console.log(`Started daemon process with PID: ${daemonProcess.pid}`);
|
||||
|
||||
// Wait for daemon to be ready (check for socket file)
|
||||
const maxWaitTime = 10000; // 10 seconds
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
if (await this.isDaemonRunning()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
throw new Error('Daemon failed to start within timeout period');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the daemon
|
||||
*/
|
||||
public async stopDaemon(graceful: boolean = true): Promise<void> {
|
||||
if (!(await this.isDaemonRunning())) {
|
||||
console.log('Daemon is not running');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.connect();
|
||||
await this.request('daemon:shutdown', {
|
||||
graceful,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
console.log('Daemon shutdown initiated');
|
||||
|
||||
// Wait for daemon to actually stop
|
||||
const maxWaitTime = 15000; // 15 seconds
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
if (!(await this.isDaemonRunning())) {
|
||||
console.log('Daemon stopped successfully');
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
console.warn(
|
||||
'Daemon did not stop within timeout, it may still be running',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error stopping daemon:', error);
|
||||
|
||||
// Try to kill the process directly if graceful shutdown failed
|
||||
try {
|
||||
const fs = await import('fs');
|
||||
const pidContent = await fs.promises.readFile(
|
||||
this.daemonPidFile,
|
||||
'utf-8',
|
||||
);
|
||||
const pid = parseInt(pidContent.trim(), 10);
|
||||
process.kill(pid, 'SIGKILL');
|
||||
console.log('Force killed daemon process');
|
||||
} catch {
|
||||
console.error('Could not force kill daemon');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daemon status
|
||||
*/
|
||||
public async getDaemonStatus(): Promise<ResponseForMethod<'daemon:status'> | null> {
|
||||
try {
|
||||
if (!(await this.isDaemonRunning())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.connect();
|
||||
return await this.request('daemon:status', {});
|
||||
} catch (error) {
|
||||
console.error('Error getting daemon status:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const tspmIpcClient = new TspmIpcClient();
|
@@ -1,15 +1,16 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { ProcessWrapper } from './classes.processwrapper.js';
|
||||
import { ProcessWrapper, type IProcessLog } from './classes.processwrapper.js';
|
||||
import { Logger, ProcessError, handleError } from './utils.errorhandler.js';
|
||||
|
||||
export interface IMonitorConfig {
|
||||
name?: string; // Optional name to identify the instance
|
||||
projectDir: string; // Directory where the command will run
|
||||
command: string; // Full command to run (e.g., "npm run xyz")
|
||||
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)
|
||||
name?: string; // Optional name to identify the instance
|
||||
projectDir: string; // Directory where the command will run
|
||||
command: string; // Full command to run (e.g., "npm run xyz")
|
||||
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 {
|
||||
@@ -18,9 +19,11 @@ export class ProcessMonitor {
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
private stopped: boolean = true; // Initially stopped until start() is called
|
||||
private restartCount: number = 0;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(config: IMonitorConfig) {
|
||||
this.config = config;
|
||||
this.logger = new Logger(`ProcessMonitor:${config.name || 'unnamed'}`);
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
@@ -31,16 +34,24 @@ export class ProcessMonitor {
|
||||
|
||||
// Set the monitoring interval.
|
||||
const interval = this.config.monitorIntervalMs || 5000;
|
||||
this.intervalId = setInterval(() => {
|
||||
this.intervalId = setInterval((): void => {
|
||||
if (this.processWrapper && this.processWrapper.getPid()) {
|
||||
this.monitorProcessGroup(this.processWrapper.getPid()!, this.config.memoryLimitBytes);
|
||||
this.monitorProcessGroup(
|
||||
this.processWrapper.getPid()!,
|
||||
this.config.memoryLimitBytes,
|
||||
);
|
||||
}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
private spawnProcess(): void {
|
||||
// Don't spawn if the monitor has been stopped.
|
||||
if (this.stopped) return;
|
||||
if (this.stopped) {
|
||||
this.logger.debug('Not spawning process because monitor is stopped');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info(`Spawning process: ${this.config.command}`);
|
||||
|
||||
// Create a new process wrapper
|
||||
this.processWrapper = new ProcessWrapper({
|
||||
@@ -53,7 +64,7 @@ export class ProcessMonitor {
|
||||
});
|
||||
|
||||
// Set up event handlers
|
||||
this.processWrapper.on('log', (log) => {
|
||||
this.processWrapper.on('log', (log: IProcessLog): void => {
|
||||
// Here we could add handlers to send logs somewhere
|
||||
// For now, we just log system messages to the console
|
||||
if (log.type === 'system') {
|
||||
@@ -61,53 +72,101 @@ export class ProcessMonitor {
|
||||
}
|
||||
});
|
||||
|
||||
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(
|
||||
'exit',
|
||||
(code: number | null, signal: string | null): void => {
|
||||
const exitMsg = `Process exited with code ${code}, signal ${signal}.`;
|
||||
this.logger.info(exitMsg);
|
||||
this.log(exitMsg);
|
||||
|
||||
if (!this.stopped) {
|
||||
this.logger.info('Restarting process...');
|
||||
this.log('Restarting process...');
|
||||
this.restartCount++;
|
||||
this.spawnProcess();
|
||||
} else {
|
||||
this.logger.debug(
|
||||
'Not restarting process because monitor is stopped',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.processWrapper.on('error', (error: Error | ProcessError): void => {
|
||||
const errorMsg =
|
||||
error instanceof ProcessError
|
||||
? `Process error: ${error.toString()}`
|
||||
: `Process error: ${error.message}`;
|
||||
|
||||
this.logger.error(error);
|
||||
this.log(errorMsg);
|
||||
|
||||
this.processWrapper.on('error', (error) => {
|
||||
this.log(`Process error: ${error.message}`);
|
||||
if (!this.stopped) {
|
||||
this.logger.info('Restarting process due to error...');
|
||||
this.log('Restarting process due to error...');
|
||||
this.restartCount++;
|
||||
this.spawnProcess();
|
||||
} else {
|
||||
this.logger.debug('Not restarting process because monitor is stopped');
|
||||
}
|
||||
});
|
||||
|
||||
// Start the process
|
||||
this.processWrapper.start();
|
||||
try {
|
||||
this.processWrapper.start();
|
||||
} catch (error: Error | unknown) {
|
||||
// The process wrapper will handle logging the error
|
||||
// Just prevent it from bubbling up further
|
||||
this.logger.error(
|
||||
`Failed to start process: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
private async monitorProcessGroup(
|
||||
pid: number,
|
||||
memoryLimit: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const memoryUsage = await this.getProcessGroupMemory(pid);
|
||||
|
||||
this.logger.debug(
|
||||
`Memory usage for PID ${pid}: ${this.humanReadableBytes(memoryUsage)} (${memoryUsage} bytes)`,
|
||||
);
|
||||
|
||||
// Only log to the process log at longer intervals to avoid spamming
|
||||
this.log(
|
||||
`Current memory usage for process group (PID ${pid}): ${this.humanReadableBytes(
|
||||
memoryUsage
|
||||
)} (${memoryUsage} bytes)`
|
||||
memoryUsage,
|
||||
)} (${memoryUsage} bytes)`,
|
||||
);
|
||||
|
||||
if (memoryUsage > memoryLimit) {
|
||||
this.log(
|
||||
`Memory usage ${this.humanReadableBytes(
|
||||
memoryUsage
|
||||
)} exceeds limit of ${this.humanReadableBytes(memoryLimit)}. Restarting process.`
|
||||
);
|
||||
const memoryLimitMsg = `Memory usage ${this.humanReadableBytes(
|
||||
memoryUsage,
|
||||
)} exceeds limit of ${this.humanReadableBytes(memoryLimit)}. Restarting process.`;
|
||||
|
||||
this.logger.warn(memoryLimitMsg);
|
||||
this.log(memoryLimitMsg);
|
||||
|
||||
// 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);
|
||||
} catch (error: Error | unknown) {
|
||||
const processError = new ProcessError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
'ERR_MEMORY_MONITORING_FAILED',
|
||||
{ pid },
|
||||
);
|
||||
|
||||
this.logger.error(processError);
|
||||
this.log(`Error monitoring process group: ${processError.toString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,19 +175,58 @@ export class ProcessMonitor {
|
||||
*/
|
||||
private getProcessGroupMemory(pid: number): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
plugins.psTree(pid, (err, children) => {
|
||||
if (err) return reject(err);
|
||||
// Include the main process and its children.
|
||||
const pids: number[] = [pid, ...children.map(child => Number(child.PID))];
|
||||
plugins.pidusage(pids, (err, stats) => {
|
||||
if (err) return reject(err);
|
||||
let totalMemory = 0;
|
||||
for (const key in stats) {
|
||||
totalMemory += stats[key].memory;
|
||||
this.logger.debug(
|
||||
`Getting memory usage for process group with PID ${pid}`,
|
||||
);
|
||||
|
||||
plugins.psTree(
|
||||
pid,
|
||||
(err: Error | null, children: Array<{ PID: string }>) => {
|
||||
if (err) {
|
||||
const processError = new ProcessError(
|
||||
`Failed to get process tree: ${err.message}`,
|
||||
'ERR_PSTREE_FAILED',
|
||||
{ pid },
|
||||
);
|
||||
this.logger.debug(`psTree error: ${err.message}`);
|
||||
return reject(processError);
|
||||
}
|
||||
resolve(totalMemory);
|
||||
});
|
||||
});
|
||||
|
||||
// Include the main process and its children.
|
||||
const pids: number[] = [
|
||||
pid,
|
||||
...children.map((child) => Number(child.PID)),
|
||||
];
|
||||
this.logger.debug(
|
||||
`Found ${pids.length} processes in group with parent PID ${pid}`,
|
||||
);
|
||||
|
||||
plugins.pidusage(
|
||||
pids,
|
||||
(err: Error | null, stats: Record<string, { memory: number }>) => {
|
||||
if (err) {
|
||||
const processError = new ProcessError(
|
||||
`Failed to get process usage stats: ${err.message}`,
|
||||
'ERR_PIDUSAGE_FAILED',
|
||||
{ pids },
|
||||
);
|
||||
this.logger.debug(`pidusage error: ${err.message}`);
|
||||
return reject(processError);
|
||||
}
|
||||
|
||||
let totalMemory = 0;
|
||||
for (const key in stats) {
|
||||
totalMemory += stats[key].memory;
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Total memory for process group: ${this.humanReadableBytes(totalMemory)}`,
|
||||
);
|
||||
resolve(totalMemory);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,38 +255,38 @@ export class ProcessMonitor {
|
||||
this.processWrapper.stop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the current logs from the process
|
||||
*/
|
||||
public getLogs(limit?: number): Array<{ timestamp: Date, type: string, message: string }> {
|
||||
public getLogs(limit?: number): IProcessLog[] {
|
||||
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
|
||||
*/
|
||||
@@ -203,4 +301,4 @@ export class ProcessMonitor {
|
||||
const prefix = this.config.name ? `[${this.config.name}] ` : '';
|
||||
console.log(prefix + message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { EventEmitter } from 'events';
|
||||
import { Logger, ProcessError, handleError } from './utils.errorhandler.js';
|
||||
|
||||
export interface IProcessWrapperOptions {
|
||||
command: string;
|
||||
@@ -22,26 +23,34 @@ export class ProcessWrapper extends EventEmitter {
|
||||
private logs: IProcessLog[] = [];
|
||||
private logBufferSize: number;
|
||||
private startTime: Date | null = null;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor(options: IProcessWrapperOptions) {
|
||||
super();
|
||||
this.options = options;
|
||||
this.logBufferSize = options.logBuffer || 100;
|
||||
this.logger = new Logger(`ProcessWrapper:${options.name}`);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Start the wrapped process
|
||||
*/
|
||||
public start(): void {
|
||||
this.addSystemLog('Starting process...');
|
||||
|
||||
|
||||
try {
|
||||
this.logger.debug(`Starting process: ${this.options.command}`);
|
||||
|
||||
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
|
||||
});
|
||||
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, {
|
||||
@@ -51,21 +60,29 @@ export class ProcessWrapper extends EventEmitter {
|
||||
shell: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
this.startTime = new Date();
|
||||
|
||||
|
||||
// Handle process exit
|
||||
this.process.on('exit', (code, signal) => {
|
||||
this.addSystemLog(`Process exited with code ${code}, signal ${signal}`);
|
||||
const exitMessage = `Process exited with code ${code}, signal ${signal}`;
|
||||
this.logger.info(exitMessage);
|
||||
this.addSystemLog(exitMessage);
|
||||
this.emit('exit', code, signal);
|
||||
});
|
||||
|
||||
|
||||
// Handle errors
|
||||
this.process.on('error', (error) => {
|
||||
this.addSystemLog(`Process error: ${error.message}`);
|
||||
this.emit('error', error);
|
||||
const processError = new ProcessError(
|
||||
error.message,
|
||||
'ERR_PROCESS_EXECUTION',
|
||||
{ command: this.options.command, pid: this.process?.pid },
|
||||
);
|
||||
this.logger.error(processError);
|
||||
this.addSystemLog(`Process error: ${processError.toString()}`);
|
||||
this.emit('error', processError);
|
||||
});
|
||||
|
||||
|
||||
// Capture stdout
|
||||
if (this.process.stdout) {
|
||||
this.process.stdout.on('data', (data) => {
|
||||
@@ -77,7 +94,7 @@ export class ProcessWrapper extends EventEmitter {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Capture stderr
|
||||
if (this.process.stderr) {
|
||||
this.process.stderr.on('data', (data) => {
|
||||
@@ -89,57 +106,86 @@ export class ProcessWrapper extends EventEmitter {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
this.addSystemLog(`Process started with PID ${this.process.pid}`);
|
||||
this.logger.info(`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;
|
||||
} catch (error: Error | unknown) {
|
||||
const processError =
|
||||
error instanceof ProcessError
|
||||
? error
|
||||
: new ProcessError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
'ERR_PROCESS_START_FAILED',
|
||||
{ command: this.options.command },
|
||||
);
|
||||
|
||||
this.logger.error(processError);
|
||||
this.addSystemLog(`Failed to start process: ${processError.toString()}`);
|
||||
this.emit('error', processError);
|
||||
throw processError;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Stop the wrapped process
|
||||
*/
|
||||
public stop(): void {
|
||||
if (!this.process) {
|
||||
this.logger.debug('Stop called but no process is running');
|
||||
this.addSystemLog('No process running');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.logger.info('Stopping process...');
|
||||
this.addSystemLog('Stopping process...');
|
||||
|
||||
|
||||
// First try SIGTERM for graceful shutdown
|
||||
if (this.process.pid) {
|
||||
try {
|
||||
this.logger.debug(`Sending SIGTERM to process ${this.process.pid}`);
|
||||
process.kill(this.process.pid, 'SIGTERM');
|
||||
|
||||
|
||||
// Give it 5 seconds to shut down gracefully
|
||||
setTimeout(() => {
|
||||
setTimeout((): void => {
|
||||
if (this.process && this.process.pid) {
|
||||
this.addSystemLog('Process did not exit gracefully, force killing...');
|
||||
this.logger.warn(
|
||||
`Process ${this.process.pid} did not exit gracefully, force killing...`,
|
||||
);
|
||||
this.addSystemLog(
|
||||
'Process did not exit gracefully, force killing...',
|
||||
);
|
||||
try {
|
||||
process.kill(this.process.pid, 'SIGKILL');
|
||||
} catch (error) {
|
||||
} catch (error: Error | unknown) {
|
||||
// Process might have exited between checks
|
||||
this.logger.debug(
|
||||
`Failed to send SIGKILL, process probably already exited: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
this.addSystemLog(`Error stopping process: ${error.message}`);
|
||||
} catch (error: Error | unknown) {
|
||||
const processError = new ProcessError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
'ERR_PROCESS_STOP_FAILED',
|
||||
{ pid: this.process.pid },
|
||||
);
|
||||
this.logger.error(processError);
|
||||
this.addSystemLog(`Error stopping process: ${processError.toString()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the process ID if running
|
||||
*/
|
||||
public getPid(): number | null {
|
||||
return this.process?.pid || null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the current logs
|
||||
*/
|
||||
@@ -147,7 +193,7 @@ export class ProcessWrapper extends EventEmitter {
|
||||
// Return the most recent logs up to the limit
|
||||
return this.logs.slice(-limit);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get uptime in milliseconds
|
||||
*/
|
||||
@@ -155,14 +201,14 @@ export class ProcessWrapper extends EventEmitter {
|
||||
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
|
||||
*/
|
||||
@@ -172,18 +218,18 @@ export class ProcessWrapper extends EventEmitter {
|
||||
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)
|
||||
*/
|
||||
@@ -193,15 +239,15 @@ export class ProcessWrapper extends EventEmitter {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,12 +1,22 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as paths from './paths.js';
|
||||
import { ProcessMonitor, type IMonitorConfig } from './classes.processmonitor.js';
|
||||
import {
|
||||
ProcessMonitor,
|
||||
type IMonitorConfig,
|
||||
} from './classes.processmonitor.js';
|
||||
import { TspmConfig } from './classes.config.js';
|
||||
import {
|
||||
Logger,
|
||||
ProcessError,
|
||||
ConfigError,
|
||||
ValidationError,
|
||||
handleError,
|
||||
} from './utils.errorhandler.js';
|
||||
|
||||
export interface IProcessConfig extends IMonitorConfig {
|
||||
id: string; // Unique identifier for the process
|
||||
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
|
||||
watch?: boolean; // Whether to watch for file changes and restart
|
||||
watchPaths?: string[]; // Paths to watch for changes
|
||||
}
|
||||
|
||||
@@ -27,151 +37,270 @@ export interface IProcessLog {
|
||||
}
|
||||
|
||||
export class Tspm {
|
||||
private processes: Map<string, ProcessMonitor> = new Map();
|
||||
private processConfigs: Map<string, IProcessConfig> = new Map();
|
||||
private processInfo: Map<string, IProcessInfo> = new Map();
|
||||
public processes: Map<string, ProcessMonitor> = new Map();
|
||||
public processConfigs: Map<string, IProcessConfig> = new Map();
|
||||
public processInfo: Map<string, IProcessInfo> = new Map();
|
||||
private config: TspmConfig;
|
||||
private configStorageKey = 'processes';
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor() {
|
||||
this.logger = new Logger('Tspm');
|
||||
this.config = new TspmConfig();
|
||||
this.loadProcessConfigs();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Start a new process with the given configuration
|
||||
*/
|
||||
public async start(config: IProcessConfig): Promise<void> {
|
||||
this.logger.info(`Starting process with id '${config.id}'`);
|
||||
|
||||
// Validate config
|
||||
if (!config.id || !config.command || !config.projectDir) {
|
||||
throw new ValidationError(
|
||||
'Invalid process configuration: missing required fields',
|
||||
'ERR_INVALID_CONFIG',
|
||||
{ config },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if process with this id already exists
|
||||
if (this.processes.has(config.id)) {
|
||||
throw new Error(`Process with id '${config.id}' already exists`);
|
||||
throw new ValidationError(
|
||||
`Process with id '${config.id}' already exists`,
|
||||
'ERR_DUPLICATE_PROCESS',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 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,
|
||||
env: config.env,
|
||||
logBufferSize: config.logBufferSize,
|
||||
});
|
||||
|
||||
this.processes.set(config.id, monitor);
|
||||
monitor.start();
|
||||
|
||||
// Update process info
|
||||
this.updateProcessInfo(config.id, { status: 'online' });
|
||||
|
||||
// Save updated configs
|
||||
await this.saveProcessConfigs();
|
||||
|
||||
this.logger.info(`Successfully started process with id '${config.id}'`);
|
||||
} catch (error: Error | unknown) {
|
||||
// Clean up in case of error
|
||||
this.processConfigs.delete(config.id);
|
||||
this.processInfo.delete(config.id);
|
||||
this.processes.delete(config.id);
|
||||
|
||||
if (error instanceof Error) {
|
||||
this.logger.error(error);
|
||||
throw new ProcessError(
|
||||
`Failed to start process: ${error.message}`,
|
||||
'ERR_PROCESS_START_FAILED',
|
||||
{ id: config.id, command: config.command },
|
||||
);
|
||||
} else {
|
||||
const genericError = new ProcessError(
|
||||
`Failed to start process: ${String(error)}`,
|
||||
'ERR_PROCESS_START_FAILED',
|
||||
{ id: config.id },
|
||||
);
|
||||
this.logger.error(genericError);
|
||||
throw genericError;
|
||||
}
|
||||
}
|
||||
|
||||
// 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> {
|
||||
this.logger.info(`Stopping process with id '${id}'`);
|
||||
|
||||
const monitor = this.processes.get(id);
|
||||
if (!monitor) {
|
||||
throw new Error(`Process with id '${id}' not found`);
|
||||
const error = new ValidationError(
|
||||
`Process with id '${id}' not found`,
|
||||
'ERR_PROCESS_NOT_FOUND',
|
||||
);
|
||||
this.logger.error(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
monitor.stop();
|
||||
this.updateProcessInfo(id, { status: 'stopped' });
|
||||
|
||||
|
||||
try {
|
||||
monitor.stop();
|
||||
this.updateProcessInfo(id, { status: 'stopped' });
|
||||
this.logger.info(`Successfully stopped process with id '${id}'`);
|
||||
} catch (error: Error | unknown) {
|
||||
const processError = new ProcessError(
|
||||
`Failed to stop process: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'ERR_PROCESS_STOP_FAILED',
|
||||
{ id },
|
||||
);
|
||||
this.logger.error(processError);
|
||||
throw processError;
|
||||
}
|
||||
|
||||
// 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> {
|
||||
this.logger.info(`Restarting process with id '${id}'`);
|
||||
|
||||
const monitor = this.processes.get(id);
|
||||
const config = this.processConfigs.get(id);
|
||||
|
||||
|
||||
if (!monitor || !config) {
|
||||
throw new Error(`Process with id '${id}' not found`);
|
||||
const error = new ValidationError(
|
||||
`Process with id '${id}' not found`,
|
||||
'ERR_PROCESS_NOT_FOUND',
|
||||
);
|
||||
this.logger.error(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
try {
|
||||
// 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,
|
||||
env: config.env,
|
||||
logBufferSize: config.logBufferSize,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.info(`Successfully restarted process with id '${id}'`);
|
||||
} catch (error: Error | unknown) {
|
||||
const processError = new ProcessError(
|
||||
`Failed to restart process: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'ERR_PROCESS_RESTART_FAILED',
|
||||
{ id },
|
||||
);
|
||||
this.logger.error(processError);
|
||||
throw processError;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Delete a process by id
|
||||
*/
|
||||
public async delete(id: string): Promise<void> {
|
||||
this.logger.info(`Deleting process with id '${id}'`);
|
||||
|
||||
// Check if process exists
|
||||
if (!this.processConfigs.has(id)) {
|
||||
const error = new ValidationError(
|
||||
`Process with id '${id}' not found`,
|
||||
'ERR_PROCESS_NOT_FOUND',
|
||||
);
|
||||
this.logger.error(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Stop the process if it's running
|
||||
try {
|
||||
await this.stop(id);
|
||||
} catch (error) {
|
||||
// Ignore errors if the process is not running
|
||||
if (this.processes.has(id)) {
|
||||
await this.stop(id);
|
||||
}
|
||||
|
||||
// Remove from all maps
|
||||
this.processes.delete(id);
|
||||
this.processConfigs.delete(id);
|
||||
this.processInfo.delete(id);
|
||||
|
||||
// Save updated configs
|
||||
await this.saveProcessConfigs();
|
||||
|
||||
this.logger.info(`Successfully deleted process with id '${id}'`);
|
||||
} catch (error: Error | unknown) {
|
||||
// Even if stop fails, we should still try to delete the configuration
|
||||
try {
|
||||
this.processes.delete(id);
|
||||
this.processConfigs.delete(id);
|
||||
this.processInfo.delete(id);
|
||||
await this.saveProcessConfigs();
|
||||
|
||||
this.logger.info(
|
||||
`Successfully deleted process with id '${id}' after stopping failure`,
|
||||
);
|
||||
} catch (deleteError: Error | unknown) {
|
||||
const configError = new ConfigError(
|
||||
`Failed to delete process configuration: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`,
|
||||
'ERR_CONFIG_DELETE_FAILED',
|
||||
{ id },
|
||||
);
|
||||
this.logger.error(configError);
|
||||
throw configError;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
*/
|
||||
@@ -180,10 +309,10 @@ export class Tspm {
|
||||
if (!monitor) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
return monitor.getLogs(limit);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Start all saved processes
|
||||
*/
|
||||
@@ -194,7 +323,7 @@ export class Tspm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Stop all running processes
|
||||
*/
|
||||
@@ -203,7 +332,7 @@ export class Tspm {
|
||||
await this.stop(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Restart all processes
|
||||
*/
|
||||
@@ -212,7 +341,7 @@ export class Tspm {
|
||||
await this.restart(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the info for a process
|
||||
*/
|
||||
@@ -222,38 +351,83 @@ export class Tspm {
|
||||
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));
|
||||
this.logger.debug('Saving process configurations to storage');
|
||||
|
||||
try {
|
||||
const configs = Array.from(this.processConfigs.values());
|
||||
await this.config.writeKey(
|
||||
this.configStorageKey,
|
||||
JSON.stringify(configs),
|
||||
);
|
||||
this.logger.debug(`Saved ${configs.length} process configurations`);
|
||||
} catch (error: Error | unknown) {
|
||||
const configError = new ConfigError(
|
||||
`Failed to save process configurations: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'ERR_CONFIG_SAVE_FAILED',
|
||||
);
|
||||
this.logger.error(configError);
|
||||
throw configError;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load process configurations from config storage
|
||||
*/
|
||||
private async loadProcessConfigs(): Promise<void> {
|
||||
public async loadProcessConfigs(): Promise<void> {
|
||||
this.logger.debug('Loading process configurations from storage');
|
||||
|
||||
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
|
||||
});
|
||||
try {
|
||||
const configs = JSON.parse(configsJson) as IProcessConfig[];
|
||||
this.logger.debug(`Loaded ${configs.length} process configurations`);
|
||||
|
||||
for (const config of configs) {
|
||||
// Validate config
|
||||
if (!config.id || !config.command || !config.projectDir) {
|
||||
this.logger.warn(
|
||||
`Skipping invalid process config for id '${config.id || 'unknown'}'`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.processConfigs.set(config.id, config);
|
||||
|
||||
// Initialize process info
|
||||
this.processInfo.set(config.id, {
|
||||
id: config.id,
|
||||
status: 'stopped',
|
||||
memory: 0,
|
||||
restarts: 0,
|
||||
});
|
||||
}
|
||||
} catch (parseError: Error | unknown) {
|
||||
const configError = new ConfigError(
|
||||
`Failed to parse process configurations: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
|
||||
'ERR_CONFIG_PARSE_FAILED',
|
||||
);
|
||||
this.logger.error(configError);
|
||||
throw configError;
|
||||
}
|
||||
} else {
|
||||
this.logger.info('No saved process configurations found');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: Error | unknown) {
|
||||
// Only throw if it's not the "no configs found" case
|
||||
if (error instanceof ConfigError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// If no configs found or error reading, just continue with empty configs
|
||||
console.log('No saved process configurations found');
|
||||
this.logger.info(
|
||||
'No saved process configurations found or error reading them',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
629
ts/cli.ts
629
ts/cli.ts
@@ -1,29 +1,638 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as paths from './paths.js';
|
||||
import { tspmIpcClient } from './classes.ipcclient.js';
|
||||
import { Logger, LogLevel } from './utils.errorhandler.js';
|
||||
import type { IProcessConfig } from './classes.tspm.js';
|
||||
|
||||
export const run = async () => {
|
||||
export interface CliArguments {
|
||||
verbose?: boolean;
|
||||
watch?: boolean;
|
||||
memory?: string;
|
||||
cwd?: string;
|
||||
daemon?: boolean;
|
||||
test?: boolean;
|
||||
name?: string;
|
||||
autorestart?: boolean;
|
||||
watchPaths?: string[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Helper function to parse memory strings (e.g., "512MB", "2GB")
|
||||
function parseMemoryString(memStr: string): number {
|
||||
const units = {
|
||||
KB: 1024,
|
||||
MB: 1024 * 1024,
|
||||
GB: 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
const match = memStr.toUpperCase().match(/^(\d+(?:\.\d+)?)\s*(KB|MB|GB)?$/);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Invalid memory format: ${memStr}. Use format like "512MB" or "2GB"`,
|
||||
);
|
||||
}
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = (match[2] || 'MB') as keyof typeof units;
|
||||
|
||||
return Math.floor(value * units[unit]);
|
||||
}
|
||||
|
||||
// Helper function to format memory for display
|
||||
function formatMemory(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
} else if (bytes >= 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
} else if (bytes >= 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
} else {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for padding strings
|
||||
function pad(str: string, length: number): string {
|
||||
return str.length > length
|
||||
? str.substring(0, length - 3) + '...'
|
||||
: str.padEnd(length);
|
||||
}
|
||||
|
||||
export const run = async (): Promise<void> => {
|
||||
const cliLogger = new Logger('CLI');
|
||||
const tspmProjectinfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
|
||||
|
||||
// Check if debug mode is enabled
|
||||
const debugMode = process.env.TSPM_DEBUG === 'true';
|
||||
if (debugMode) {
|
||||
cliLogger.setLevel(LogLevel.DEBUG);
|
||||
cliLogger.debug('Debug mode enabled');
|
||||
}
|
||||
|
||||
const smartcliInstance = new plugins.smartcli.Smartcli();
|
||||
smartcliInstance.addVersion(tspmProjectinfo.npm.version);
|
||||
|
||||
|
||||
// Default command - show help and list processes
|
||||
smartcliInstance.standardCommand().subscribe({
|
||||
next: (argvArg) => {
|
||||
console.log(`Please specify a command.`)
|
||||
next: async (argvArg: CliArguments) => {
|
||||
console.log(
|
||||
`TSPM - TypeScript Process Manager v${tspmProjectinfo.npm.version}`,
|
||||
);
|
||||
console.log('Usage: tspm [command] [options]');
|
||||
console.log('\nCommands:');
|
||||
console.log(' start <script> Start a process');
|
||||
console.log(' list List all processes');
|
||||
console.log(' stop <id> Stop a process');
|
||||
console.log(' restart <id> Restart a process');
|
||||
console.log(' delete <id> Delete a process');
|
||||
console.log(' describe <id> Show details for a process');
|
||||
console.log(' logs <id> Show logs for a process');
|
||||
console.log(' start-all Start all saved processes');
|
||||
console.log(' stop-all Stop all processes');
|
||||
console.log(' restart-all Restart all processes');
|
||||
console.log('\nDaemon Commands:');
|
||||
console.log(' daemon start Start the TSPM daemon');
|
||||
console.log(' daemon stop Stop the TSPM daemon');
|
||||
console.log(' daemon status Show daemon status');
|
||||
console.log(
|
||||
'\nUse tspm [command] --help for more information about a command.',
|
||||
);
|
||||
|
||||
// Show current process list
|
||||
console.log('\nProcess List:');
|
||||
|
||||
try {
|
||||
const response = await tspmIpcClient.request('list', {});
|
||||
const processes = response.processes;
|
||||
|
||||
if (processes.length === 0) {
|
||||
console.log(
|
||||
' No processes running. Use "tspm start" to start a process.',
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
'┌─────────┬─────────────┬───────────┬───────────┬──────────┐',
|
||||
);
|
||||
console.log(
|
||||
'│ ID │ Name │ Status │ Memory │ Restarts │',
|
||||
);
|
||||
console.log(
|
||||
'├─────────┼─────────────┼───────────┼───────────┼──────────┤',
|
||||
);
|
||||
|
||||
for (const proc of processes) {
|
||||
const statusColor =
|
||||
proc.status === 'online'
|
||||
? '\x1b[32m'
|
||||
: proc.status === 'errored'
|
||||
? '\x1b[31m'
|
||||
: '\x1b[33m';
|
||||
const resetColor = '\x1b[0m';
|
||||
|
||||
console.log(
|
||||
`│ ${pad(proc.id, 7)} │ ${pad(proc.id, 11)} │ ${statusColor}${pad(proc.status, 9)}${resetColor} │ ${pad(formatMemory(proc.memory), 9)} │ ${pad(proc.restarts.toString(), 8)} │`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
'└─────────┴─────────────┴───────────┴───────────┴──────────┘',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error: Could not connect to TSPM daemon. Use "tspm daemon start" to start it.',
|
||||
);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
cliLogger.error(err);
|
||||
},
|
||||
complete: () => {},
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('restart').subscribe({
|
||||
// Start command
|
||||
smartcliInstance.addCommand('start').subscribe({
|
||||
next: async (argvArg: CliArguments) => {
|
||||
try {
|
||||
const script = argvArg._[1];
|
||||
if (!script) {
|
||||
console.error('Error: Please provide a script to run');
|
||||
console.log('Usage: tspm start <script> [options]');
|
||||
console.log('\nOptions:');
|
||||
console.log(' --name <name> Name for the process');
|
||||
console.log(
|
||||
' --memory <size> Memory limit (e.g., "512MB", "2GB")',
|
||||
);
|
||||
console.log(' --cwd <path> Working directory');
|
||||
console.log(
|
||||
' --watch Watch for file changes and restart',
|
||||
);
|
||||
console.log(' --watch-paths <paths> Comma-separated paths to watch');
|
||||
console.log(' --autorestart Auto-restart on crash');
|
||||
return;
|
||||
}
|
||||
|
||||
})
|
||||
const memoryLimit = argvArg.memory
|
||||
? parseMemoryString(argvArg.memory)
|
||||
: 512 * 1024 * 1024; // Default 512MB
|
||||
const projectDir = argvArg.cwd || process.cwd();
|
||||
const name = argvArg.name || script;
|
||||
const watch = argvArg.watch || false;
|
||||
const autorestart = argvArg.autorestart !== false; // Default true
|
||||
const watchPaths = argvArg.watchPaths
|
||||
? typeof argvArg.watchPaths === 'string'
|
||||
? (argvArg.watchPaths as string).split(',')
|
||||
: argvArg.watchPaths
|
||||
: undefined;
|
||||
|
||||
smartcliInstance.addCommand('startAsDaemon').subscribe({
|
||||
const processConfig: IProcessConfig = {
|
||||
id: name.replace(/[^a-zA-Z0-9-_]/g, '_'),
|
||||
name,
|
||||
command: script,
|
||||
projectDir,
|
||||
memoryLimitBytes: memoryLimit,
|
||||
autorestart,
|
||||
watch,
|
||||
watchPaths,
|
||||
};
|
||||
|
||||
})
|
||||
console.log(`Starting process: ${name}`);
|
||||
console.log(` Command: ${script}`);
|
||||
console.log(` Directory: ${projectDir}`);
|
||||
console.log(` Memory limit: ${formatMemory(memoryLimit)}`);
|
||||
console.log(` Auto-restart: ${autorestart}`);
|
||||
if (watch) {
|
||||
console.log(` Watch mode: enabled`);
|
||||
if (watchPaths) {
|
||||
console.log(` Watch paths: ${watchPaths.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await tspmIpcClient.request('start', {
|
||||
config: processConfig,
|
||||
});
|
||||
|
||||
console.log(`✓ Process started successfully`);
|
||||
console.log(` ID: ${response.processId}`);
|
||||
console.log(` PID: ${response.pid || 'N/A'}`);
|
||||
console.log(` Status: ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error('Error starting process:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
cliLogger.error(err);
|
||||
},
|
||||
complete: () => {},
|
||||
});
|
||||
|
||||
// Stop command
|
||||
smartcliInstance.addCommand('stop').subscribe({
|
||||
next: async (argvArg: CliArguments) => {
|
||||
try {
|
||||
const id = argvArg._[1];
|
||||
if (!id) {
|
||||
console.error('Error: Please provide a process ID');
|
||||
console.log('Usage: tspm stop <id>');
|
||||
return;
|
||||
}
|
||||
|
||||
})
|
||||
console.log(`Stopping process: ${id}`);
|
||||
const response = await tspmIpcClient.request('stop', { id });
|
||||
|
||||
if (response.success) {
|
||||
console.log(`✓ ${response.message}`);
|
||||
} else {
|
||||
console.error(`✗ Failed to stop process: ${response.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping process:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
cliLogger.error(err);
|
||||
},
|
||||
complete: () => {},
|
||||
});
|
||||
|
||||
// Restart command
|
||||
smartcliInstance.addCommand('restart').subscribe({
|
||||
next: async (argvArg: CliArguments) => {
|
||||
try {
|
||||
const id = argvArg._[1];
|
||||
if (!id) {
|
||||
console.error('Error: Please provide a process ID');
|
||||
console.log('Usage: tspm restart <id>');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Restarting process: ${id}`);
|
||||
const response = await tspmIpcClient.request('restart', { id });
|
||||
|
||||
console.log(`✓ Process restarted successfully`);
|
||||
console.log(` ID: ${response.processId}`);
|
||||
console.log(` PID: ${response.pid || 'N/A'}`);
|
||||
console.log(` Status: ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error('Error restarting process:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
cliLogger.error(err);
|
||||
},
|
||||
complete: () => {},
|
||||
});
|
||||
|
||||
// Delete command
|
||||
smartcliInstance.addCommand('delete').subscribe({
|
||||
next: async (argvArg: CliArguments) => {
|
||||
try {
|
||||
const id = argvArg._[1];
|
||||
if (!id) {
|
||||
console.error('Error: Please provide a process ID');
|
||||
console.log('Usage: tspm delete <id>');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Deleting process: ${id}`);
|
||||
const response = await tspmIpcClient.request('delete', { id });
|
||||
|
||||
if (response.success) {
|
||||
console.log(`✓ ${response.message}`);
|
||||
} else {
|
||||
console.error(`✗ Failed to delete process: ${response.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting process:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
cliLogger.error(err);
|
||||
},
|
||||
complete: () => {},
|
||||
});
|
||||
|
||||
// List command
|
||||
smartcliInstance.addCommand('list').subscribe({
|
||||
next: async (argvArg: CliArguments) => {
|
||||
try {
|
||||
const response = await tspmIpcClient.request('list', {});
|
||||
const processes = response.processes;
|
||||
|
||||
if (processes.length === 0) {
|
||||
console.log('No processes running.');
|
||||
} else {
|
||||
console.log('Process List:');
|
||||
console.log(
|
||||
'┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────┐',
|
||||
);
|
||||
console.log(
|
||||
'│ ID │ Name │ Status │ PID │ Memory │ Restarts │',
|
||||
);
|
||||
console.log(
|
||||
'├─────────┼─────────────┼───────────┼───────────┼──────────┼──────────┤',
|
||||
);
|
||||
|
||||
for (const proc of processes) {
|
||||
const statusColor =
|
||||
proc.status === 'online'
|
||||
? '\x1b[32m'
|
||||
: proc.status === 'errored'
|
||||
? '\x1b[31m'
|
||||
: '\x1b[33m';
|
||||
const resetColor = '\x1b[0m';
|
||||
|
||||
console.log(
|
||||
`│ ${pad(proc.id, 7)} │ ${pad(proc.id, 11)} │ ${statusColor}${pad(proc.status, 9)}${resetColor} │ ${pad((proc.pid || '-').toString(), 9)} │ ${pad(formatMemory(proc.memory), 8)} │ ${pad(proc.restarts.toString(), 8)} │`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
'└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┘',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error listing processes:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
cliLogger.error(err);
|
||||
},
|
||||
complete: () => {},
|
||||
});
|
||||
|
||||
// Describe command
|
||||
smartcliInstance.addCommand('describe').subscribe({
|
||||
next: async (argvArg: CliArguments) => {
|
||||
try {
|
||||
const id = argvArg._[1];
|
||||
if (!id) {
|
||||
console.error('Error: Please provide a process ID');
|
||||
console.log('Usage: tspm describe <id>');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await tspmIpcClient.request('describe', { id });
|
||||
|
||||
console.log(`Process Details: ${id}`);
|
||||
console.log('─'.repeat(40));
|
||||
console.log(`Status: ${response.processInfo.status}`);
|
||||
console.log(`PID: ${response.processInfo.pid || 'N/A'}`);
|
||||
console.log(
|
||||
`Memory: ${formatMemory(response.processInfo.memory)}`,
|
||||
);
|
||||
console.log(
|
||||
`CPU: ${response.processInfo.cpu ? response.processInfo.cpu.toFixed(1) + '%' : 'N/A'}`,
|
||||
);
|
||||
console.log(
|
||||
`Uptime: ${response.processInfo.uptime ? Math.floor(response.processInfo.uptime / 1000) + 's' : 'N/A'}`,
|
||||
);
|
||||
console.log(`Restarts: ${response.processInfo.restarts}`);
|
||||
console.log('\nConfiguration:');
|
||||
console.log(`Command: ${response.config.command}`);
|
||||
console.log(`Directory: ${response.config.projectDir}`);
|
||||
console.log(
|
||||
`Memory Limit: ${formatMemory(response.config.memoryLimitBytes)}`,
|
||||
);
|
||||
console.log(`Auto-restart: ${response.config.autorestart}`);
|
||||
if (response.config.watch) {
|
||||
console.log(`Watch: enabled`);
|
||||
if (response.config.watchPaths) {
|
||||
console.log(
|
||||
`Watch Paths: ${response.config.watchPaths.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error describing process:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
cliLogger.error(err);
|
||||
},
|
||||
complete: () => {},
|
||||
});
|
||||
|
||||
// Logs command
|
||||
smartcliInstance.addCommand('logs').subscribe({
|
||||
next: async (argvArg: CliArguments) => {
|
||||
try {
|
||||
const id = argvArg._[1];
|
||||
if (!id) {
|
||||
console.error('Error: Please provide a process ID');
|
||||
console.log('Usage: tspm logs <id>');
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = argvArg.lines || 50;
|
||||
const response = await tspmIpcClient.request('getLogs', { id, lines });
|
||||
|
||||
console.log(`Logs for process: ${id} (last ${lines} lines)`);
|
||||
console.log('─'.repeat(60));
|
||||
|
||||
for (const log of response.logs) {
|
||||
const timestamp = new Date(log.timestamp).toLocaleTimeString();
|
||||
const prefix = log.type === 'stdout' ? '[OUT]' : '[ERR]';
|
||||
console.log(`${timestamp} ${prefix} ${log.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting logs:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
cliLogger.error(err);
|
||||
},
|
||||
complete: () => {},
|
||||
});
|
||||
|
||||
// Start-all command
|
||||
smartcliInstance.addCommand('start-all').subscribe({
|
||||
next: async (argvArg: CliArguments) => {
|
||||
try {
|
||||
console.log('Starting all processes...');
|
||||
const response = await tspmIpcClient.request('startAll', {});
|
||||
|
||||
if (response.started.length > 0) {
|
||||
console.log(`✓ Started ${response.started.length} processes:`);
|
||||
for (const id of response.started) {
|
||||
console.log(` - ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.failed.length > 0) {
|
||||
console.log(`✗ Failed to start ${response.failed.length} processes:`);
|
||||
for (const failure of response.failed) {
|
||||
console.log(` - ${failure.id}: ${failure.error}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting all processes:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
cliLogger.error(err);
|
||||
},
|
||||
complete: () => {},
|
||||
});
|
||||
|
||||
// Stop-all command
|
||||
smartcliInstance.addCommand('stop-all').subscribe({
|
||||
next: async (argvArg: CliArguments) => {
|
||||
try {
|
||||
console.log('Stopping all processes...');
|
||||
const response = await tspmIpcClient.request('stopAll', {});
|
||||
|
||||
if (response.stopped.length > 0) {
|
||||
console.log(`✓ Stopped ${response.stopped.length} processes:`);
|
||||
for (const id of response.stopped) {
|
||||
console.log(` - ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.failed.length > 0) {
|
||||
console.log(`✗ Failed to stop ${response.failed.length} processes:`);
|
||||
for (const failure of response.failed) {
|
||||
console.log(` - ${failure.id}: ${failure.error}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping all processes:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
cliLogger.error(err);
|
||||
},
|
||||
complete: () => {},
|
||||
});
|
||||
|
||||
// Restart-all command
|
||||
smartcliInstance.addCommand('restart-all').subscribe({
|
||||
next: async (argvArg: CliArguments) => {
|
||||
try {
|
||||
console.log('Restarting all processes...');
|
||||
const response = await tspmIpcClient.request('restartAll', {});
|
||||
|
||||
if (response.restarted.length > 0) {
|
||||
console.log(`✓ Restarted ${response.restarted.length} processes:`);
|
||||
for (const id of response.restarted) {
|
||||
console.log(` - ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.failed.length > 0) {
|
||||
console.log(
|
||||
`✗ Failed to restart ${response.failed.length} processes:`,
|
||||
);
|
||||
for (const failure of response.failed) {
|
||||
console.log(` - ${failure.id}: ${failure.error}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error restarting all processes:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
cliLogger.error(err);
|
||||
},
|
||||
complete: () => {},
|
||||
});
|
||||
|
||||
// Daemon commands
|
||||
smartcliInstance.addCommand('daemon').subscribe({
|
||||
next: async (argvArg: CliArguments) => {
|
||||
const subCommand = argvArg._[1];
|
||||
|
||||
switch (subCommand) {
|
||||
case 'start':
|
||||
try {
|
||||
const status = await tspmIpcClient.getDaemonStatus();
|
||||
if (status) {
|
||||
console.log('TSPM daemon is already running');
|
||||
console.log(` PID: ${status.pid}`);
|
||||
console.log(
|
||||
` Uptime: ${Math.floor((status.uptime || 0) / 1000)}s`,
|
||||
);
|
||||
console.log(` Processes: ${status.processCount}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Starting TSPM daemon...');
|
||||
await tspmIpcClient.connect();
|
||||
console.log('✓ TSPM daemon started successfully');
|
||||
|
||||
const newStatus = await tspmIpcClient.getDaemonStatus();
|
||||
if (newStatus) {
|
||||
console.log(` PID: ${newStatus.pid}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting daemon:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
try {
|
||||
console.log('Stopping TSPM daemon...');
|
||||
await tspmIpcClient.stopDaemon(true);
|
||||
console.log('✓ TSPM daemon stopped successfully');
|
||||
} catch (error) {
|
||||
console.error('Error stopping daemon:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
try {
|
||||
const status = await tspmIpcClient.getDaemonStatus();
|
||||
if (!status) {
|
||||
console.log('TSPM daemon is not running');
|
||||
console.log('Use "tspm daemon start" to start it');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('TSPM Daemon Status:');
|
||||
console.log('─'.repeat(40));
|
||||
console.log(`Status: ${status.status}`);
|
||||
console.log(`PID: ${status.pid}`);
|
||||
console.log(
|
||||
`Uptime: ${Math.floor((status.uptime || 0) / 1000)}s`,
|
||||
);
|
||||
console.log(`Processes: ${status.processCount}`);
|
||||
console.log(
|
||||
`Memory: ${formatMemory(status.memoryUsage || 0)}`,
|
||||
);
|
||||
console.log(`CPU: ${status.cpuUsage?.toFixed(1) || 0}s`);
|
||||
} catch (error) {
|
||||
console.error('Error getting daemon status:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Usage: tspm daemon <command>');
|
||||
console.log('\nCommands:');
|
||||
console.log(' start Start the TSPM daemon');
|
||||
console.log(' stop Stop the TSPM daemon');
|
||||
console.log(' status Show daemon status');
|
||||
break;
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
cliLogger.error(err);
|
||||
},
|
||||
complete: () => {},
|
||||
});
|
||||
|
||||
// Start parsing commands
|
||||
smartcliInstance.startParse();
|
||||
}
|
||||
};
|
||||
|
9
ts/daemon.ts
Normal file
9
ts/daemon.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { startDaemon } from './classes.daemon.js';
|
||||
|
||||
// Start the daemon
|
||||
startDaemon().catch((error) => {
|
||||
console.error('Failed to start daemon:', error);
|
||||
process.exit(1);
|
||||
});
|
@@ -1,5 +1,8 @@
|
||||
export * from './classes.tspm.js';
|
||||
export * from './classes.processmonitor.js';
|
||||
export * from './classes.daemon.js';
|
||||
export * from './classes.ipcclient.js';
|
||||
export * from './ipc.types.js';
|
||||
|
||||
import * as cli from './cli.js';
|
||||
|
||||
@@ -8,4 +11,4 @@ import * as cli from './cli.js';
|
||||
*/
|
||||
export const runCli = async () => {
|
||||
await cli.run();
|
||||
}
|
||||
};
|
||||
|
201
ts/ipc.types.ts
Normal file
201
ts/ipc.types.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import type {
|
||||
IProcessConfig,
|
||||
IProcessInfo,
|
||||
IProcessLog,
|
||||
} from './classes.tspm.js';
|
||||
|
||||
// Base message types
|
||||
export interface IpcRequest<T = any> {
|
||||
id: string;
|
||||
method: string;
|
||||
params: T;
|
||||
}
|
||||
|
||||
export interface IpcResponse<T = any> {
|
||||
id: string;
|
||||
success: boolean;
|
||||
result?: T;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: any;
|
||||
};
|
||||
}
|
||||
|
||||
// Request/Response pairs for each operation
|
||||
|
||||
// Start command
|
||||
export interface StartRequest {
|
||||
config: IProcessConfig;
|
||||
}
|
||||
|
||||
export interface StartResponse {
|
||||
processId: string;
|
||||
pid?: number;
|
||||
status: 'online' | 'stopped' | 'errored';
|
||||
}
|
||||
|
||||
// Stop command
|
||||
export interface StopRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface StopResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Restart command
|
||||
export interface RestartRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface RestartResponse {
|
||||
processId: string;
|
||||
pid?: number;
|
||||
status: 'online' | 'stopped' | 'errored';
|
||||
}
|
||||
|
||||
// Delete command
|
||||
export interface DeleteRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface DeleteResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// List command
|
||||
export interface ListRequest {
|
||||
// No parameters needed
|
||||
}
|
||||
|
||||
export interface ListResponse {
|
||||
processes: IProcessInfo[];
|
||||
}
|
||||
|
||||
// Describe command
|
||||
export interface DescribeRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface DescribeResponse {
|
||||
processInfo: IProcessInfo;
|
||||
config: IProcessConfig;
|
||||
}
|
||||
|
||||
// Get logs command
|
||||
export interface GetLogsRequest {
|
||||
id: string;
|
||||
lines?: number;
|
||||
}
|
||||
|
||||
export interface GetLogsResponse {
|
||||
logs: IProcessLog[];
|
||||
}
|
||||
|
||||
// Start all command
|
||||
export interface StartAllRequest {
|
||||
// No parameters needed
|
||||
}
|
||||
|
||||
export interface StartAllResponse {
|
||||
started: string[];
|
||||
failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Stop all command
|
||||
export interface StopAllRequest {
|
||||
// No parameters needed
|
||||
}
|
||||
|
||||
export interface StopAllResponse {
|
||||
stopped: string[];
|
||||
failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Restart all command
|
||||
export interface RestartAllRequest {
|
||||
// No parameters needed
|
||||
}
|
||||
|
||||
export interface RestartAllResponse {
|
||||
restarted: string[];
|
||||
failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Daemon status command
|
||||
export interface DaemonStatusRequest {
|
||||
// No parameters needed
|
||||
}
|
||||
|
||||
export interface DaemonStatusResponse {
|
||||
status: 'running' | 'stopped';
|
||||
pid?: number;
|
||||
uptime?: number;
|
||||
processCount: number;
|
||||
memoryUsage?: number;
|
||||
cpuUsage?: number;
|
||||
}
|
||||
|
||||
// Daemon shutdown command
|
||||
export interface DaemonShutdownRequest {
|
||||
graceful?: boolean;
|
||||
timeout?: number; // milliseconds
|
||||
}
|
||||
|
||||
export interface DaemonShutdownResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Heartbeat command
|
||||
export interface HeartbeatRequest {
|
||||
// No parameters needed
|
||||
}
|
||||
|
||||
export interface HeartbeatResponse {
|
||||
timestamp: number;
|
||||
status: 'healthy' | 'degraded';
|
||||
}
|
||||
|
||||
// Type mappings for methods
|
||||
export type IpcMethodMap = {
|
||||
start: { request: StartRequest; response: StartResponse };
|
||||
stop: { request: StopRequest; response: StopResponse };
|
||||
restart: { request: RestartRequest; response: RestartResponse };
|
||||
delete: { request: DeleteRequest; response: DeleteResponse };
|
||||
list: { request: ListRequest; response: ListResponse };
|
||||
describe: { request: DescribeRequest; response: DescribeResponse };
|
||||
getLogs: { request: GetLogsRequest; response: GetLogsResponse };
|
||||
startAll: { request: StartAllRequest; response: StartAllResponse };
|
||||
stopAll: { request: StopAllRequest; response: StopAllResponse };
|
||||
restartAll: { request: RestartAllRequest; response: RestartAllResponse };
|
||||
'daemon:status': {
|
||||
request: DaemonStatusRequest;
|
||||
response: DaemonStatusResponse;
|
||||
};
|
||||
'daemon:shutdown': {
|
||||
request: DaemonShutdownRequest;
|
||||
response: DaemonShutdownResponse;
|
||||
};
|
||||
heartbeat: { request: HeartbeatRequest; response: HeartbeatResponse };
|
||||
};
|
||||
|
||||
// Helper type to extract request type for a method
|
||||
export type RequestForMethod<M extends keyof IpcMethodMap> =
|
||||
IpcMethodMap[M]['request'];
|
||||
|
||||
// Helper type to extract response type for a method
|
||||
export type ResponseForMethod<M extends keyof IpcMethodMap> =
|
||||
IpcMethodMap[M]['response'];
|
10
ts/paths.ts
10
ts/paths.ts
@@ -1,4 +1,10 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export const packageDir = plugins.path.join(plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url), '..');
|
||||
export const cwd = process.cwd();
|
||||
export const packageDir: string = plugins.path.join(
|
||||
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||
'..',
|
||||
);
|
||||
export const cwd: string = process.cwd();
|
||||
|
||||
import * as os from 'os';
|
||||
export const tspmDir: string = plugins.path.join(os.homedir(), '.tspm');
|
||||
|
@@ -2,29 +2,23 @@
|
||||
import * as childProcess from 'child_process';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export {
|
||||
childProcess,
|
||||
path,
|
||||
}
|
||||
// Export with explicit module types
|
||||
export { childProcess, path };
|
||||
|
||||
// @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';
|
||||
import * as smartdaemon from '@push.rocks/smartdaemon';
|
||||
import * as smartipc from '@push.rocks/smartipc';
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
|
||||
export {
|
||||
npmextra,
|
||||
projectinfo,
|
||||
smartpath,
|
||||
smartcli,
|
||||
}
|
||||
// Export with explicit module types
|
||||
export { npmextra, projectinfo, smartcli, smartdaemon, smartipc, smartpath };
|
||||
|
||||
// third-party scope
|
||||
import psTree from 'ps-tree';
|
||||
import pidusage from 'pidusage';
|
||||
|
||||
export {
|
||||
psTree,
|
||||
pidusage,
|
||||
}
|
||||
// Add explicit types for third-party exports
|
||||
export { psTree, pidusage };
|
||||
|
152
ts/utils.errorhandler.ts
Normal file
152
ts/utils.errorhandler.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Centralized error handling utility for TSPM
|
||||
*/
|
||||
|
||||
// Define error types
|
||||
export enum ErrorType {
|
||||
CONFIG = 'ConfigError',
|
||||
PROCESS = 'ProcessError',
|
||||
RUNTIME = 'RuntimeError',
|
||||
VALIDATION = 'ValidationError',
|
||||
UNKNOWN = 'UnknownError',
|
||||
}
|
||||
|
||||
// Base error class with type and code support
|
||||
export class TspmError extends Error {
|
||||
type: ErrorType;
|
||||
code: string;
|
||||
details?: Record<string, any>;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
type: ErrorType = ErrorType.UNKNOWN,
|
||||
code: string = 'ERR_UNKNOWN',
|
||||
details?: Record<string, any>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = type;
|
||||
this.type = type;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
|
||||
// Preserve proper stack trace
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `[${this.type}:${this.code}] ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Specific error classes
|
||||
export class ConfigError extends TspmError {
|
||||
constructor(
|
||||
message: string,
|
||||
code: string = 'ERR_CONFIG',
|
||||
details?: Record<string, any>,
|
||||
) {
|
||||
super(message, ErrorType.CONFIG, code, details);
|
||||
}
|
||||
}
|
||||
|
||||
export class ProcessError extends TspmError {
|
||||
constructor(
|
||||
message: string,
|
||||
code: string = 'ERR_PROCESS',
|
||||
details?: Record<string, any>,
|
||||
) {
|
||||
super(message, ErrorType.PROCESS, code, details);
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends TspmError {
|
||||
constructor(
|
||||
message: string,
|
||||
code: string = 'ERR_VALIDATION',
|
||||
details?: Record<string, any>,
|
||||
) {
|
||||
super(message, ErrorType.VALIDATION, code, details);
|
||||
}
|
||||
}
|
||||
|
||||
// Utility for handling any error type
|
||||
export const handleError = (error: Error | unknown): TspmError => {
|
||||
if (error instanceof TspmError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new TspmError(error.message, ErrorType.UNKNOWN, 'ERR_UNKNOWN', {
|
||||
originalError: error,
|
||||
});
|
||||
}
|
||||
|
||||
return new TspmError(String(error), ErrorType.UNKNOWN, 'ERR_UNKNOWN');
|
||||
};
|
||||
|
||||
// Logger with different log levels
|
||||
export enum LogLevel {
|
||||
DEBUG = 0,
|
||||
INFO = 1,
|
||||
WARN = 2,
|
||||
ERROR = 3,
|
||||
NONE = 4,
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
private static instance: Logger;
|
||||
private level: LogLevel = LogLevel.INFO;
|
||||
private componentName: string;
|
||||
|
||||
constructor(componentName: string) {
|
||||
this.componentName = componentName;
|
||||
}
|
||||
|
||||
static getInstance(componentName: string): Logger {
|
||||
if (!Logger.instance) {
|
||||
Logger.instance = new Logger(componentName);
|
||||
}
|
||||
return Logger.instance;
|
||||
}
|
||||
|
||||
setLevel(level: LogLevel): void {
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
private formatMessage(message: string): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
return `[${timestamp}] [${this.componentName}] ${message}`;
|
||||
}
|
||||
|
||||
debug(message: string): void {
|
||||
if (this.level <= LogLevel.DEBUG) {
|
||||
console.log(this.formatMessage(`DEBUG: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
if (this.level <= LogLevel.INFO) {
|
||||
console.log(this.formatMessage(message));
|
||||
}
|
||||
}
|
||||
|
||||
warn(message: string): void {
|
||||
if (this.level <= LogLevel.WARN) {
|
||||
console.warn(this.formatMessage(`WARNING: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
error(error: Error | unknown): void {
|
||||
if (this.level <= LogLevel.ERROR) {
|
||||
const tspmError = handleError(error);
|
||||
console.error(this.formatMessage(`ERROR: ${tspmError.toString()}`));
|
||||
|
||||
// In debug mode, also log stack trace
|
||||
if (this.level === LogLevel.DEBUG && tspmError.stack) {
|
||||
console.error(tspmError.stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -11,7 +11,5 @@
|
||||
"baseUrl": ".",
|
||||
"paths": {}
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
"exclude": ["dist_*/**/*.d.ts"]
|
||||
}
|
||||
|
Reference in New Issue
Block a user