Compare commits

...

32 Commits

Author SHA1 Message Date
c7c1bbb460 v5.10.4
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-24 19:00:14 +00:00
70c925a780 fix(crash-logging): migrate filesystem persistence to smartfs and stabilize crash log tests 2026-03-24 19:00:14 +00:00
0f794f76e8 v5.10.3
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-24 15:06:39 +00:00
ec57cc7c42 fix(config): replace npmextra with smartconfig for daemon key-value storage and release settings 2026-03-24 15:06:39 +00:00
f1d685b819 5.10.2
Some checks failed
Default (tags) / security (push) Failing after 12m9s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-09-03 11:47:06 +00:00
61c4aabba3 fix(processmonitor): Bump smartdaemon and stop aggressive pidusage cache clearing in ProcessMonitor 2025-09-03 11:47:06 +00:00
f10a7847c2 5.10.1
Some checks failed
Default (tags) / security (push) Failing after 12m11s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-09-03 08:27:06 +00:00
3a39fbd65f fix(processmonitor): Skip null pidusage entries when aggregating process-group memory/CPU to avoid errors 2025-09-03 08:27:06 +00:00
e208384d41 5.10.0
Some checks failed
Default (tags) / security (push) Successful in 51s
Default (tags) / test (push) Failing after 4m37s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-09-01 10:32:51 +00:00
c9d924811d feat(daemon): Add crash log manager with rotation and integrate crash logging; improve IPC & process listener cleanup 2025-09-01 10:32:51 +00:00
9473924fcc 5.9.0
Some checks failed
Default (tags) / security (push) Successful in 51s
Default (tags) / test (push) Failing after 3m57s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-31 16:36:06 +00:00
a0e7408c1a feat(cli): Add interactive edit flow to CLI and improve UX 2025-08-31 16:36:06 +00:00
6e39b1db8f 5.8.0
Some checks failed
Default (tags) / security (push) Successful in 48s
Default (tags) / test (push) Failing after 3m57s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-31 08:08:27 +00:00
ee4532221a feat(core): Add core TypeScript TSPM implementation: CLI, daemon, client, process management and tests 2025-08-31 08:08:27 +00:00
e39173a827 5.7.0
Some checks failed
Default (tags) / security (push) Failing after 13m15s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-08-31 08:06:03 +00:00
6f14033d9b feat(cli): Add stats CLI command and daemon stats aggregation; fix process manager & wrapper state handling 2025-08-31 08:06:03 +00:00
1c4ffbb612 5.6.2
Some checks failed
Default (tags) / security (push) Successful in 50s
Default (tags) / test (push) Failing after 12m37s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-08-31 07:45:48 +00:00
0a75c4cf76 fix(processmanager): Improve process lifecycle handling and cleanup in daemon, monitors and wrappers 2025-08-31 07:45:47 +00:00
8f31672a67 5.6.1
Some checks failed
Default (tags) / security (push) Successful in 51s
Default (tags) / test (push) Failing after 3m57s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-31 00:01:50 +00:00
b3087831e2 fix(daemon): Ensure robust process shutdown and improve logs/subscriber diagnostics 2025-08-31 00:01:50 +00:00
4160b3f031 5.6.0
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 3m57s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-30 23:36:26 +00:00
fa50ce40c8 feat(processmonitor): Add CPU monitoring and display CPU in process list 2025-08-30 23:36:26 +00:00
8f96118e0c 5.5.0
Some checks failed
Default (tags) / security (push) Successful in 50s
Default (tags) / test (push) Failing after 3m58s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-30 23:26:59 +00:00
b210efde2a feat(logs): Improve logs streaming and backlog delivery; add CLI filters and ndjson output 2025-08-30 23:26:59 +00:00
d8709d8b94 5.4.2
Some checks failed
Default (tags) / security (push) Successful in 49s
Default (tags) / test (push) Failing after 3m58s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-30 22:16:44 +00:00
43799f3431 fix(cli/process/logs): Reset log sequence on process restart to avoid false log gap warnings 2025-08-30 22:16:44 +00:00
f4cbdd51e1 5.4.1
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 3m58s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-30 22:08:24 +00:00
1340c1c248 fix(processmonitor): Bump tsbuild devDependency and relax ps-tree callback typing in ProcessMonitor 2025-08-30 22:08:24 +00:00
92a6ecac71 5.4.0
Some checks failed
Default (tags) / security (push) Successful in 40s
Default (tags) / test (push) Failing after 3m58s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-30 22:01:19 +00:00
5e26b0ab5f feat(daemon): Add CLI systemd service refresh on version mismatch and fix daemon memory leak; update dependencies 2025-08-30 22:01:19 +00:00
e09cf38f30 5.3.2
Some checks failed
Default (tags) / security (push) Successful in 50s
Default (tags) / test (push) Failing after 3m58s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-30 21:50:43 +00:00
c694672438 fix(daemon): Improve daemon log delivery and process monitor memory accounting; gate debug output and update tests to numeric ProcessId 2025-08-30 21:50:43 +00:00
35 changed files with 4147 additions and 3527 deletions

View File

@@ -14,5 +14,14 @@
"npmci": {
"npmGlobalTools": [],
"npmAccessLevel": "public"
},
"@git.zone/cli": {
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
}
}
}

View File

@@ -1,7 +1,7 @@
{
"json.schemas": [
{
"fileMatch": ["/npmextra.json"],
"fileMatch": ["/.smartconfig.json"],
"schema": {
"type": "object",
"properties": {

View File

@@ -1,5 +1,141 @@
# Changelog
## 2026-03-24 - 5.10.4 - fix(crash-logging)
migrate filesystem persistence to smartfs and stabilize crash log tests
- replace smartfile usage with smartfs in crash log and log persistence modules
- update crash log tests to use tap assertions and current CLI command output
- move project config from npmextra.json to .smartconfig.json and refresh build dependencies
## 2026-03-24 - 5.10.3 - fix(config)
replace npmextra with smartconfig for daemon key-value storage and release settings
- swap the configuration storage dependency from @push.rocks/npmextra to @push.rocks/smartconfig
- update daemon config accessors to use the smartconfig KeyValueStore implementation
- add @git.zone/cli release registry and access configuration to npmextra.json
## 2025-09-03 - 5.10.2 - fix(processmonitor)
Bump smartdaemon and stop aggressive pidusage cache clearing in ProcessMonitor
- Update dependency @push.rocks/smartdaemon from ^2.0.9 to ^2.1.0 in package.json.
- Remove per-PID pidusage.clear calls in ts/daemon/processmonitor.ts (getProcessGroupStats) to avoid potential errors or unexpected behavior from manually clearing pidusage cache.
## 2025-09-03 - 5.10.1 - fix(processmonitor)
Skip null pidusage entries when aggregating process-group memory/CPU to avoid errors
- Add defensive check for null/undefined entries returned by pidusage before accessing memory/cpu fields
- Log a debug message when an individual process stat is null (process may have exited)
- Improve robustness of ProcessMonitor.getProcessGroupStats to prevent runtime exceptions during aggregation
## 2025-09-01 - 5.10.0 - feat(daemon)
Add crash log manager with rotation and integrate crash logging; improve IPC & process listener cleanup
- Introduce CrashLogManager to create formatted crash reports, persist them to disk and rotate old logs (max 100)
- Persist recent process logs, include metadata (exit code, signal, restart attempts, memory) and human-readable sizes in crash reports
- Integrate crash logging into ProcessMonitor: save crash logs on non-zero exits and errors, and persist/rotate logs
- Improve ProcessMonitor and ProcessWrapper by tracking and removing event listeners to avoid memory leaks
- Clear pidusage cache more aggressively to prevent stale entries
- Enhance TspmIpcClient to store/remove lifecycle event handlers on disconnect to avoid dangling listeners
- Add tests and utilities: test/test.crashlog.direct.ts, test/test.crashlog.manual.ts and test/test.crashlog.ts to validate crash log creation and rotation
## 2025-08-31 - 5.9.0 - feat(cli)
Add interactive edit flow to CLI and improve UX
- Add -i / --interactive flag to tspm add to open an interactive editor immediately after adding a process
- Implement interactiveEditProcess helper (smartinteract-based) to provide interactive editing for process configs
- Enable tspm edit to launch the interactive editor (replaces prior placeholder flow)
- Improve user-facing message when no processes are configured in tspm list
- Lower verbosity for missing saved configs on daemon startup (changed logger.info → logger.debug)
## 2025-08-31 - 5.8.0 - feat(core)
Add core TypeScript TSPM implementation: CLI, daemon, client, process management and tests
- Add CLI entrypoint and command set (start/stop/add/list/logs/daemon/service/stats/reset and batch ops)
- Add daemon implementation with ProcessManager, ProcessMonitor, ProcessWrapper, LogPersistence and config storage
- Add IPC client (tspmIpcClient) and TspmServiceManager for systemd integration using smartipc/smartdaemon
- Introduce shared protocol types, process ID helpers and standardized error codes for stable IPC
- Include tests and test assets for daemon, integration and IPC client scenarios
- Add README and package metadata (package.json, npmextra.json, commitinfo)
## 2025-08-31 - 5.7.0 - feat(cli)
Add 'stats' CLI command and daemon stats aggregation; fix process manager & wrapper state handling
- Add new 'stats' CLI command to show daemon + process statistics (memory, CPU, uptime, logs in memory, paths, configs) and include it in the default help output
- Implement daemon-side aggregation for logs-in-memory, per-process log counts/bytes, and expose tspmDir/socket/pidFile and config counts in daemon:status
- Enhance startById handler to detect already-running monitors and return current status/pid instead of attempting to restart
- Improve ProcessManager start/restart/stop behavior: if an existing monitor exists but is not running, restart it; ensure PID and status are updated consistently (clear PID on stop)
- Fix ProcessWrapper lifecycle handling: clear internal process reference on exit, improve isRunning() and getPid() semantics to reflect actual runtime state
- Update IPC types to include optional metadata fields (paths, configs, logsInMemory) in DaemonStatusResponse
## 2025-08-31 - 5.6.2 - fix(processmanager)
Improve process lifecycle handling and cleanup in daemon, monitors and wrappers
- StartAll: when a monitor exists but is not running, restart it instead of skipping — ensures saved processes are reliably brought online.
- ProcessMonitor.stop: cancel any pending restart timers to prevent stray restarts after explicit stop.
- ProcessWrapper: add killProcessTree helper and use it for graceful (SIGTERM) and force (SIGKILL) shutdowns to reliably signal child processes.
- Daemon stopAll: yield briefly after stopping processes and inspect monitors (not only processInfo) to accurately report stopped vs failed processes.
## 2025-08-31 - 5.6.1 - fix(daemon)
Ensure robust process shutdown and improve logs/subscriber diagnostics
- Make ProcessWrapper.stop asynchronous and awaitable to avoid race conditions when stopping processes
- Signal entire process groups on POSIX (kill by negative PID) and fall back to per-PID signalling; escalate to SIGKILL after a timeout
- Await processWrapper.stop() from ProcessMonitor when enforcing memory limits or handling exits/errors to ensure child processes are cleaned up
- Add logs:subscribers IPC endpoint and corresponding types to inspect current subscribers for a process log topic
- Add optional CLI debug output in logs command (enabled via TSPM_DEBUG=true) to print subscriber counts and details
- Support passing request.lines to getLogs handler in daemon to limit returned log entries
## 2025-08-30 - 5.6.0 - feat(processmonitor)
Add CPU monitoring and display CPU in process list
- CLI: show a CPU column in the `tspm list` output (adds formatting and placeholder name display)
- Daemon: ProcessMonitor now collects CPU usage for the process group in addition to memory
- Daemon: ProcessMonitor exposes getLastCpuUsage() and ProcessManager syncs CPU values into IProcessInfo
- Non-breaking: UI and internal stats enriched to surface CPU metrics for processes
## 2025-08-30 - 5.5.0 - feat(logs)
Improve logs streaming and backlog delivery; add CLI filters and ndjson output
- CLI: add new logs options: --since, --stderr-only, --stdout-only and --ndjson; enhance streaming output and gap detection
- CLI: fetch backlog conditionally (honoring --since) and print filtered results before live streaming
- Client: add TspmIpcClient.requestLogsBacklogStream, onStream and onBacklogTopic helpers to receive backlog chunks and streams
- Daemon: add logs:subscribe IPC handler to stream backlog entries to requesting client in small batches
- Protocol: extend IPC types with LogsSubscribeRequest/Response and register 'logs:subscribe' method
- Dependency: bump @push.rocks/smartipc to ^2.3.0 to support the streaming/IPC changes
## 2025-08-30 - 5.4.2 - fix(cli/process/logs)
Reset log sequence on process restart to avoid false log gap warnings
- Track process runId when streaming logs and initialize lastRunId from fetched logs
- When a new runId is detected, reset lastSeq so that subsequent streamed logs are accepted (prevents spurious gap warnings)
- Emit an informational message when a restart/runId change is detected to aid debugging of log streams
## 2025-08-30 - 5.4.1 - fix(processmonitor)
Bump tsbuild devDependency and relax ps-tree callback typing in ProcessMonitor
- Update devDependency @git.zone/tsbuild from ^2.6.7 to ^2.6.8
- Change psTree callback types in ts/daemon/processmonitor.ts to accept any error and ReadonlyArray for children to improve type compatibility
## 2025-08-30 - 5.4.0 - feat(daemon)
Add CLI systemd service refresh on version mismatch and fix daemon memory leak; update dependencies
- CLI: when client and daemon versions differ, prompt to refresh the systemd service and optionally disable/enable the service automatically
- Daemon: clear pidusage state for PIDs on process exit/stop to prevent memory leaks in long-running monitors
- Client: expose smartdaemon in client plugin exports and fix import path for tspm.servicemanager
- Package: tighten dependency ranges (set specific versions) and add @types for pidusage and ps-tree
- Misc: ensure IPC disconnects and PID/socket handling improvements were integrated alongside the above changes
## 2025-08-30 - 5.3.2 - fix(daemon)
Improve daemon log delivery and process monitor memory accounting; gate debug output and update tests to numeric ProcessId
- Deliver process logs only to subscribed clients instead of broadcasting to all connections (reduce unnecessary IPC traffic and noise)
- Implement incremental log memory accounting in ProcessMonitor using an estimateLogSize helper and WeakMap to avoid repeated JSON.stringify and reduce CPU/memory overhead
- Seed the incremental size map when loading persisted logs so memory accounting is accurate after restart
- Trim logs incrementally by subtracting estimated sizes of removed entries (avoids O(n) recalculation)
- Gate verbose console/debug output behind TSPM_DEBUG to prevent spamming in normal runs (applies to ProcessWrapper and ProcessMonitor)
- Improve process wrapper stdout/stderr debug logging to be conditional on debug mode
- Update tests to use numeric ProcessId via toProcessId(...) for consistency with typed IDs
## 2025-08-30 - 5.3.1 - fix(client(tspmIpcClient))
Use bare topic names for IPC client subscribe/unsubscribe to fix log subscription issues

21
license Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Task Venture Capital GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,6 +1,6 @@
{
"name": "@git.zone/tspm",
"version": "5.3.1",
"version": "5.10.4",
"private": false,
"description": "a no fuzz process manager",
"main": "dist_ts/index.js",
@@ -16,7 +16,7 @@
"license": "MIT",
"scripts": {
"test": "(tstest test/ --verbose --logfile --timeout 60)",
"build": "(tsbuild --web --allowimplicitany)",
"build": "(tsbuild)",
"buildDocs": "(tsdoc)",
"start": "(tsrun ./cli.ts -v)"
},
@@ -24,25 +24,27 @@
"tspm": "./cli.js"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.6.7",
"@git.zone/tsbundle": "^2.5.1",
"@git.zone/tsrun": "^1.2.46",
"@git.zone/tstest": "^2.3.5",
"@git.zone/tsbuild": "^4.3.0",
"@git.zone/tsbundle": "^2.9.3",
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.5.1",
"@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^22.13.10"
"@types/node": "^25.5.0"
},
"dependencies": {
"@push.rocks/npmextra": "^5.3.3",
"@push.rocks/projectinfo": "^5.0.2",
"@push.rocks/smartcli": "^4.0.11",
"@push.rocks/smartdaemon": "^2.0.9",
"@push.rocks/smartfile": "^11.2.7",
"@push.rocks/smartcli": "^4.0.20",
"@push.rocks/smartconfig": "^6.0.1",
"@push.rocks/smartdaemon": "^2.1.0",
"@push.rocks/smartfs": "^1.5.0",
"@push.rocks/smartinteract": "^2.0.16",
"@push.rocks/smartipc": "^2.2.2",
"@push.rocks/smartipc": "^2.3.0",
"@push.rocks/smartpath": "^6.0.0",
"@types/pidusage": "^2.0.5",
"@types/ps-tree": "^1.1.6",
"pidusage": "^4.0.1",
"ps-tree": "^1.2.0",
"tsx": "^4.20.5"
"tsx": "^4.21.0"
},
"repository": {
"type": "git",
@@ -61,7 +63,7 @@
"dist_ts_web/**/*",
"assets/**/*",
"cli.js",
"npmextra.json",
".smartconfig.json",
"readme.md"
],
"pnpm": {

4865
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,16 @@
# Project Readme Hints
This is the initial readme hints file.
## Build Tools
- Uses `@git.zone/tsbuild` v4.x — just `tsbuild` with no extra flags needed
- Uses `@git.zone/tstest` v3.x — zero-config, file naming convention for runtime selection
- Uses `@git.zone/tsrun` v2.x — zero-config TypeScript execution via tsx
## Key Architectural Decisions
- **No smartfile dependency** — replaced with native Node.js `fs/promises` for filesystem operations in crashlogmanager.ts and logpersistence.ts (smartfile v13 removed the `fs` and `memory` namespaces)
- **smartconfig** (not npmextra) — configuration stored in `.smartconfig.json` using `@push.rocks/smartconfig` KeyValueStore
- **Three test categories**: unit tests (test.ts, test.daemon.ts, test.ipcclient.ts), direct tests (test.crashlog.direct.ts), and integration tests (test.crashlog.ts, test.crashlog.manual.ts) that require a running daemon
## Integration Tests
- The crashlog integration tests (`test.crashlog.ts`, `test.crashlog.manual.ts`) depend on a running daemon and CLI output parsing
- They gracefully skip when daemon can't be started in the test environment
- The CLI `add` command outputs `Assigned ID: <number>` (not `Process added with ID:`)

582
readme.md
View File

@@ -1,317 +1,268 @@
# @git.zone/tspm 🚀
**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.
**TypeScript Process Manager** A robust, no-fuss process manager built for the modern TypeScript and Node.js ecosystem. Production-ready process management without the bloat.
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## 🎯 What is TSPM?
TSPM (TypeScript Process Manager) is your production-ready process manager that handles the hard parts of running Node.js applications. It's like PM2, but built from the ground up for the modern TypeScript ecosystem with better memory management, intelligent logging, and a cleaner architecture.
TSPM is your production-ready process manager that handles the hard parts of running Node.js applications. Think PM2, but built from scratch for the TypeScript-first ecosystem with better memory management, intelligent logging, a clean daemon architecture, and native ESM support.
### ✨ Key Features
- **🧠 Smart Memory Management** - Tracks memory including child processes, enforces limits, and auto-restarts when exceeded
- **💾 Persistent Log Storage** - Keeps 10MB of logs in memory, persists to disk on restart/stop/error
- **🔄 Intelligent Auto-Restart** - Automatically restarts crashed processes with configurable policies
- **👀 File Watching** - Auto-restart on file changes for seamless development
- **🌳 Process Group Tracking** - Monitors parent and all child processes as a unit
- **🏗️ Daemon Architecture** - Survives terminal sessions with Unix socket IPC
- **📊 Beautiful CLI** - Clean, informative output with real-time status updates
- **📝 Structured Logging** - Captures stdout/stderr with timestamps and metadata
- **⚡ Zero Config** - Works out of the box, customize when needed
- **🔌 System Service** - Run as systemd service for production deployments
- 🧠 **Smart Memory Management** Tracks memory across entire process trees (parent + children), enforces limits, and auto-restarts on OOM
- 💾 **Persistent Log Storage** — 10MB in-memory ring buffer per process, auto-persists to disk on stop/restart/crash
- 🔄 **Intelligent Auto-Restart** — Crashed processes restart with incremental backoff (1s → 10s), auto-stop after 10 consecutive failures
- 👀 **File Watching** Auto-restart on file changes for seamless development workflows
- 🌳 **Process Tree Tracking** Monitors parent and all child processes as a unit — no orphans, ever
- 🏗️ **Daemon Architecture** — Persistent background service that survives terminal sessions via Unix socket IPC
- 📊 **Beautiful CLI** Clean, informative output with table views, real-time log streaming, and search
- **Zero Config** — Works out of the box; customize only when you need to
- 🔌 **Systemd Integration** — Run as a system service for production deployments with `tspm enable`
- 🔍 **Crash Log Reports** — Detailed crash reports with metadata, memory snapshots, and log history
## 📦 Installation
```bash
# Install globally (recommended)
npm install -g @git.zone/tspm
# Or with pnpm
# Global install (recommended)
pnpm add -g @git.zone/tspm
# Or as a dev dependency
npm install --save-dev @git.zone/tspm
# Or with npm
npm install -g @git.zone/tspm
# Or as a project dependency
pnpm add --save-dev @git.zone/tspm
```
## 🚀 Quick Start
```bash
# Add a process (creates config without starting)
# Start the daemon
tspm daemon start
# Add a process
tspm add "node server.js" --name my-server --memory 1GB
# Start the process (by name or id)
# Start it
tspm start name:my-server
# or
tspm start id:1
# Or add and start in one go
tspm add "node app.js" --name my-app
tspm start name:my-app
# List all processes
# See what's running
tspm list
# View logs
tspm logs name:my-app
tspm logs name:my-server
# Stop a process
tspm stop name:my-app
# Stop it
tspm stop name:my-server
```
## 📋 Commands
## 📋 CLI Reference
### Targeting Processes
Most commands accept flexible process targeting:
| Format | Example | Description |
|--------|---------|-------------|
| Numeric ID | `tspm start 1` | Direct ID reference |
| `id:N` | `tspm start id:1` | Explicit ID prefix |
| `name:LABEL` | `tspm start name:api` | Target by name |
Use `tspm search <query>` to find processes by name or ID substring.
### Process Management
#### `tspm add <command> [options]`
Add a new process configuration without starting it. This is the recommended way to register processes.
Register a new process configuration (without starting it).
**Options:**
- `--name <name>` - Custom name for the process (required)
- `--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
- `--autorestart` - Auto-restart on crash (default: true)
| Option | Description | Default |
|--------|-------------|---------|
| `--name <name>` | Process name | command string |
| `--memory <size>` | Memory limit (e.g. `512MB`, `2GB`) | `512MB` |
| `--cwd <path>` | Working directory | current directory |
| `--watch` | Enable file watching | `false` |
| `--watch-paths <paths>` | Comma-separated watch paths | — |
| `--autorestart` | Auto-restart on crash | `true` |
| `-i, --interactive` | Enter interactive edit after adding | — |
**Examples:**
```bash
# Add a simple Node.js app
# Simple Node.js app
tspm add "node server.js" --name api-server
# Add with 2GB memory limit
tspm add "node app.js" --name production-api --memory 2GB
# TypeScript with 2GB memory limit
tspm add "tsx src/index.ts" --name production-api --memory 2GB
# Add TypeScript app with watching
# Dev mode with file watching
tspm add "tsx watch src/index.ts" --name dev-server --watch --watch-paths "src,config"
# Add without auto-restart
tspm add "node worker.js" --name one-time-job --autorestart false
# One-shot worker (no auto-restart)
tspm add "node worker.js" --name batch-job --autorestart false
# Add + interactive edit
tspm add "node server.js" --name api -i
```
#### `tspm start <id|id:N|name:LABEL>`
#### `tspm start <target>`
Start a previously added process by its ID or name.
Start a registered process.
```bash
tspm start name:my-server
tspm start id:1 # Or a bare numeric id: tspm start 1
tspm start id:1
tspm start 1 # bare numeric id also works
```
#### `tspm stop <id|id:N|name:LABEL>`
#### `tspm stop <target>`
Gracefully stop a running process (SIGTERM → SIGKILL after timeout).
Gracefully stop a process (SIGTERM → 5s grace → SIGKILL).
```bash
tspm stop name:my-server
```
#### `tspm restart <id|id:N|name:LABEL>`
#### `tspm restart <target>`
Stop and restart a process with the same configuration.
Stop and restart a process, preserving its configuration.
```bash
tspm restart name:my-server
```
#### `tspm delete <id|id:N|name:LABEL>` / `tspm remove <id|id:N|name:LABEL>`
#### `tspm delete <target>`
Stop and remove a process from TSPM management. Also deletes persisted logs.
Stop, remove from management, and delete persisted logs.
```bash
tspm delete name:old-server
tspm remove name:old-server # Alias for delete (daemon handles delete)
```
#### `tspm edit <id>`
#### `tspm edit <target>`
Interactively edit a process configuration.
Interactively modify a process configuration (name, command, memory, etc.).
```bash
tspm edit my-server
# Opens interactive prompts to modify name, command, memory, etc.
tspm edit name:my-server
```
### Monitoring & Information
#### `tspm search <query>`
Search processes by name or ID substring.
```bash
tspm search api
# Matches for "api":
# id:3 name:api-server
```
### Monitoring
#### `tspm list`
Display all managed processes in a beautiful table.
Display all managed processes in a table.
```bash
tspm list
# Output:
┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────┐
```
┌─────┬─────────────┬──────────┬───────┬──────────┬──────────┐
│ ID │ Name │ Status │ PID │ Memory │ Restarts │
├─────────┼─────────────┼───────────┼───────────┼──────────┼──────────┤
├─────┼─────────────┼─────────────────┼──────────┼──────────┤
│ 1 │ my-app │ online │ 45123 │ 245.3 MB │ 0 │
│ 2 │ worker │ online │ 45456 │ 128.7 MB │ 2 │
│ 3 │ api-server │ stopped │ - │ 0 B │ 5 │
└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┘
└─────┴─────────────┴─────────────────┴──────────┴──────────┘
```
#### `tspm describe <id|id:N|name:LABEL>`
#### `tspm describe <target>`
Get detailed information about a specific process.
Detailed information about a specific process.
```bash
tspm describe name:my-server
# Output:
Process Details: my-server
────────────────────────────────────────
Status: online
PID: 45123
Memory: 245.3 MB
Uptime: 3600s
Restarts: 0
Configuration:
────────────────────────────────────────
Command: node server.js
Directory: /home/user/project
Memory Limit: 2 GB
Auto-restart: true
Watch: disabled
# Process Details: my-server
# ────────────────────────────────────────
# Status: online
# PID: 45123
# Memory: 245.3 MB
# Uptime: 3600s
# Restarts: 0
#
# Configuration:
# ────────────────────────────────────────
# Command: node server.js
# Directory: /home/user/project
# Memory Limit: 2 GB
# Auto-restart: true
# Watch: disabled
```
#### `tspm logs <id|id:N|name:LABEL> [options]`
#### `tspm logs <target> [options]`
View process logs (stdout and stderr combined).
View and stream process logs.
**Options:**
- `--lines <n>` - Number of lines to display (default: 50)
- `--follow` - Stream logs in real-time (like `tail -f`)
| Option | Description | Default |
|--------|-------------|---------|
| `--lines <n>` | Number of lines | `50` |
| `--since <dur>` | Time filter (`10m`, `2h`, `1d`) | — |
| `--stderr-only` | Only stderr | — |
| `--stdout-only` | Only stdout | — |
| `--ndjson` | Output as newline-delimited JSON | — |
| `--follow` | Real-time streaming (like `tail -f`) | — |
```bash
# View last 50 lines
tspm logs name:my-server
# View last 100 lines
tspm logs name:my-server --lines 100
# Last 100 lines of stderr only
tspm logs name:my-server --lines 100 --stderr-only
# Follow logs in real-time
# Stream logs in real time
tspm logs name:my-server --follow
# NDJSON output since 10 minutes ago
tspm logs name:my-server --since 10m --ndjson
```
### Batch Operations
#### `tspm start-all`
Start all saved processes at once.
```bash
tspm start-all
# ✓ Started 3 processes:
# - my-app
# - worker
# - api-server
```
#### `tspm stop-all`
Stop all running processes.
```bash
tspm stop-all
# ✓ Stopped 3 processes
```
#### `tspm restart-all`
Restart all running processes.
```bash
tspm restart-all
# ✓ Restarted 3 processes
```
#### `tspm reset`
**⚠️ Dangerous:** Stop all processes and clear all configurations.
```bash
tspm reset
# Are you sure? (y/N)
# Stopped 3 processes.
# Cleared all configurations.
tspm start-all # Start all saved processes
tspm stop-all # Stop all running processes
tspm restart-all # Restart all running processes
tspm reset # ⚠️ Stop all + clear all configs (prompts for confirmation)
```
### Daemon Management
The TSPM daemon runs in the background and manages all your processes. It starts automatically when needed.
#### `tspm daemon start`
Manually start the TSPM daemon (usually automatic).
The daemon is a persistent background service that manages all processes. It starts automatically when needed.
```bash
tspm daemon start
# ✓ TSPM daemon started successfully
tspm daemon start # Start the daemon
tspm daemon stop # Stop daemon + all managed processes
tspm daemon restart # Restart daemon (preserves processes)
tspm daemon status # Check daemon health + stats
```
#### `tspm daemon stop`
Stop the daemon and all managed processes.
### System Service (systemd)
```bash
tspm daemon stop
# ✓ TSPM daemon stopped successfully
sudo tspm enable # Install + enable as systemd service (auto-start on boot)
sudo tspm disable # Remove systemd service
```
#### `tspm daemon restart`
Restart the daemon (preserves running processes).
### Version Check
```bash
tspm daemon restart
# ✓ TSPM daemon restarted successfully
```
#### `tspm daemon status`
Check daemon health and statistics.
```bash
tspm daemon status
# Output:
TSPM Daemon Status:
────────────────────────────────────────
Status: running
PID: 12345
Uptime: 86400s
Processes: 5
Socket: /home/user/.tspm/tspm.sock
```
### System Service Management
Run TSPM as a system service (systemd) for production deployments.
#### `tspm enable`
Enable TSPM as a system service that starts on boot.
```bash
sudo tspm enable
# ✓ TSPM daemon enabled and started as system service
# The daemon will now start automatically on system boot
```
#### `tspm disable`
Disable the TSPM system service.
```bash
sudo tspm disable
# ✓ TSPM daemon service disabled
# The daemon will no longer start on system boot
tspm -v
# tspm CLI: 5.x.y
# Daemon: running v5.x.z (pid 1234)
# Version mismatch detected → optionally refresh the systemd service
```
## 🏗️ Architecture
TSPM uses a robust three-tier architecture:
TSPM uses a clean three-tier architecture:
```
┌─────────────────────────────────────────┐
@@ -343,20 +294,29 @@ TSPM uses a robust three-tier architecture:
│ │ - Stream handling │ │
│ │ - Signal management │ │
│ └──────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────┐ │
│ │ CrashLogManager │ │
│ │ - Crash report generation │ │
│ │ - Log rotation (max 100) │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
### Key Components
- **CLI** - Lightweight client that communicates with daemon via IPC
- **Daemon** - Persistent background service managing all processes
- **ProcessManager** - High-level orchestration and configuration
- **ProcessMonitor** - Adds monitoring, limits, and auto-restart
- **ProcessWrapper** - Low-level process lifecycle and streams
| Component | Role |
|-----------|------|
| **CLI** | Lightweight client that sends commands to daemon via IPC |
| **Daemon** | Persistent background service managing all processes |
| **ProcessManager** | High-level orchestration, config persistence, state management |
| **ProcessMonitor** | Memory limits, auto-restart with backoff, log persistence, file watching |
| **ProcessWrapper** | Low-level process lifecycle, stream handling, signal management |
| **CrashLogManager** | Detailed crash reports with metadata and log history |
## 🎮 Programmatic API
Use TSPM as a library in your Node.js applications:
TSPM exposes a typed IPC client for programmatic use:
```typescript
import { TspmIpcClient } from '@git.zone/tspm/client';
@@ -364,218 +324,152 @@ import { TspmIpcClient } from '@git.zone/tspm/client';
const client = new TspmIpcClient();
await client.connect();
// Add and start a process
// Add a process configuration
const { id } = await client.request('add', {
config: {
command: 'node worker.js',
name: 'background-worker',
projectDir: process.cwd(),
memoryLimit: 512 * 1024 * 1024, // 512MB in bytes
memoryLimitBytes: 512 * 1024 * 1024,
autorestart: true,
watch: false,
},
});
await client.request('start', { id });
// Start it
await client.request('startById', { id });
// Get process info
const { processInfo } = await client.request('describe', { id });
console.log(`Worker status: ${processInfo.status}`);
console.log(`Memory usage: ${processInfo.memory} bytes`);
console.log(`Status: ${processInfo.status}, Memory: ${processInfo.memory} bytes`);
// Get logs
const { logs } = await client.request('logs', { id, limit: 100 });
logs.forEach(log => {
console.log(`[${log.timestamp}] ${log.message}`);
});
const { logs } = await client.request('getLogs', { id, limit: 100 });
logs.forEach(log => console.log(`[${log.timestamp}] [${log.type}] ${log.message}`));
// Clean up
// Stop and remove
await client.request('stop', { id });
await client.request('delete', { id });
await client.disconnect();
```
### Module Exports
| Export Path | Purpose |
|-------------|---------|
| `@git.zone/tspm` | Main entry point (re-exports client + daemon) |
| `@git.zone/tspm/client` | IPC client (`TspmIpcClient`, `TspmServiceManager`) |
| `@git.zone/tspm/daemon` | Daemon entry point (`startDaemon`) |
| `@git.zone/tspm/protocol` | IPC type definitions |
## 🔧 Advanced Features
### Restart Backoff & Failure Handling
TSPM handles crashed processes with intelligent backoff:
- **Incremental delay**: Grows linearly from 1s up to 10s for consecutive restarts
- **Failure threshold**: After 10 consecutive failures, the process is marked `errored` and auto-restart stops
- **Auto-reset**: The retry counter resets if no failure occurs for 1 hour
- **Manual recovery**: `tspm restart id:1` always works, even on errored processes
### Memory Management
TSPM tracks total memory usage including all child processes:
- Uses `ps-tree` to discover child processes
- Calculates combined memory usage
- Gracefully restarts when limit exceeded
- Prevents memory leaks in production
Full process tree memory tracking:
- Discovers all child processes via `ps-tree`
- Calculates combined memory usage across the entire tree
- Gracefully restarts when limit is exceeded (SIGTERM → SIGKILL)
- Prevents memory leaks from taking down production systems
### Log Persistence
Intelligent log management system:
- Keeps 10MB of logs in memory per process
- Automatically flushes to disk on stop/restart/error
- Loads previous logs on process restart
- Cleans up persisted logs after loading
- Prevents disk space issues
Smart in-memory log management:
### Process Groups
Full process tree management:
- Tracks parent and all child processes
- Ensures complete cleanup on stop
- Accurate memory tracking across process trees
- No orphaned processes
- 10MB ring buffer per process with automatic trimming
- Flushes to disk on stop, restart, or crash
- Reloads persisted logs when process restarts
- Crash logs stored separately with full metadata (exit code, signal, memory, timestamps)
### Graceful Shutdown
Multi-stage shutdown process:
1. Send SIGTERM for graceful shutdown
2. Wait for process to clean up (5 seconds)
3. Send SIGKILL if still running
4. Clean up all child processes
Multi-stage shutdown for reliability:
1. Send **SIGTERM** for graceful shutdown
2. Wait **5 seconds** for process cleanup
3. Send **SIGKILL** if still alive
4. Clean up **all child processes** in the tree
### File Watching
Development-friendly auto-restart:
- Watch specific directories or files
- Ignore `node_modules` by default
- Debounced restart on changes
- Configurable watch paths
## 📊 Performance
TSPM is designed for production efficiency:
- **CPU Usage**: < 0.5% overhead per managed process
- **Memory**: ~30-50MB for daemon, ~5-10MB per managed process
- **Startup Time**: < 100ms to spawn new process
- **IPC Latency**: < 1ms for command execution
- **Log Performance**: Efficient ring buffer with automatic trimming
## 🛠️ Development
```bash
# Clone the repository
git clone https://code.foss.global/git.zone/tspm.git
cd tspm
# Install dependencies
pnpm install
# Run tests
pnpm test
# Build the project
pnpm build
# Run in development
pnpm start
```
### Project Structure
```
tspm/
├── ts/
│ ├── cli/ # CLI commands and interface
│ ├── client/ # IPC client for daemon communication
│ ├── daemon/ # Daemon server and process management
│ └── shared/ # Shared types and protocols
├── test/ # Test files
└── dist_ts/ # Compiled JavaScript
```
- `node_modules` ignored by default
- Debounced restart on file changes
- Configurable via `--watch-paths`
## 🐛 Debugging
Enable verbose logging for troubleshooting:
```bash
# Enable debug mode
export TSPM_DEBUG=true
tspm list
# Check daemon status
tspm daemon status
# Check daemon logs
# View process logs
tspm logs name:my-app --lines 200
# Check daemon stderr
tail -f /tmp/daemon-stderr.log
# Force daemon restart
tspm daemon restart
```
Common issues:
**Common issues:**
- **"Daemon not running"**: Run `tspm daemon start` or `tspm enable`
- **"Permission denied"**: Check socket permissions in `~/.tspm/`
- **"Process won't start"**: Check logs with `tspm logs <id|id:N|name:LABEL>`
| Problem | Solution |
|---------|----------|
| "Daemon not running" | `tspm daemon start` or `sudo tspm enable` |
| "Permission denied" | Check socket permissions in `~/.tspm/` |
| Process won't start | Check logs with `tspm logs <target>` |
| Memory limit exceeded | Increase with `tspm edit <target>` |
## 🎯 Targeting Processes (IDs and Names)
Most process commands accept the following target formats:
- Numeric ID: `tspm start 1`
- Explicit ID: `tspm start id:1`
- Explicit name: `tspm start name:api-server`
Notes:
- Names must be used with the `name:` prefix.
- If multiple processes share the same name, the CLI will report the ambiguous matches. Use `id:N` to disambiguate.
- Use `tspm search <query>` to discover IDs by name or ID fragments.
### `tspm search <query>`
Search processes by name or ID substring and print matching IDs (and names when available):
```bash
tspm search api
# Matches for "api":
# - id:3 name:api-server
```
- **"Memory limit exceeded"**: Increase limit with `tspm edit <id>`
## 🤝 Why Choose TSPM?
### TSPM vs PM2
## 🤝 Why TSPM?
| Feature | TSPM | PM2 |
|---------|------|-----|
| TypeScript Native | ✅ Built in TS | ❌ JavaScript |
| Memory Tracking | ✅ Including children | ⚠️ Main process only |
| Log Management | ✅ Smart 10MB buffer | ⚠️ Can grow unlimited |
| Architecture | ✅ Clean 3-tier | ❌ Monolithic |
| TypeScript Native | ✅ Built in TypeScript | ❌ JavaScript |
| Memory Tracking | ✅ Full process tree | ⚠️ Main process only |
| Log Management | ✅ Smart 10MB buffer | ⚠️ Can grow unbounded |
| Architecture | ✅ Clean 3-tier daemon | ❌ Monolithic |
| Dependencies | ✅ Minimal | ❌ Heavy |
| ESM Support | ✅ Native | ⚠️ Partial |
| Config Format | ✅ Simple JSON | ❌ Complex ecosystem |
| Crash Reports | ✅ Detailed with metadata | ❌ Basic |
### Perfect For
### Restart Backoff and Failure Handling
TSPM automatically restarts crashed processes with an incremental backoff:
- Debounce delay grows linearly from 1s up to 10s for consecutive retries.
- After the 10th retry, the process is marked as failed (status: "errored") and auto-restarts stop.
- The retry counter resets if no retry happens for 1 hour since the last attempt.
You can manually restart a failed process at any time:
```bash
tspm restart id:1
```
- 🚀 **Production Node.js apps** - Reliable process management
- 🔧 **Microservices** - Manage multiple services easily
- 👨‍💻 **Development** - File watching and auto-restart
- 🏭 **Worker processes** - Queue workers, cron jobs
- 📊 **Resource-constrained environments** - Memory limits prevent OOM
- 🚀 **Production Node.js apps** — Reliable process management with memory guards
- 🔧 **Microservices** — Manage multiple services from a single tool
- 👨‍💻 **Development** — File watching and instant auto-restart
- 🏭 **Workers & Jobs** — Queue workers, cron jobs, background tasks
- 📊 **Resource-constrained environments** — Memory limits prevent OOM kills
## 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.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./license) file.
**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.
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 or third parties, 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 or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District court Bremen HRB 35230 HB, Germany
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.
For any legal inquiries or 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.

View File

@@ -0,0 +1,90 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { CrashLogManager } from '../ts/daemon/crashlogmanager.js';
import type { IProcessLog } from '../ts/shared/protocol/ipc.types.js';
import * as plugins from '../ts/plugins.js';
import * as paths from '../ts/paths.js';
import * as fs from 'fs/promises';
tap.test('CrashLogManager should save and read crash logs', async () => {
const crashLogManager = new CrashLogManager();
const crashLogsDir = plugins.path.join(paths.tspmDir, 'crashlogs');
// Clean up any existing crash logs
try {
await fs.rm(crashLogsDir, { recursive: true, force: true });
} catch {}
// Create test logs
const testLogs: IProcessLog[] = [
{ timestamp: Date.now() - 5000, message: '[TEST] Process starting up...', type: 'stdout' },
{ timestamp: Date.now() - 4000, message: '[TEST] Initializing components...', type: 'stdout' },
{ timestamp: Date.now() - 3000, message: '[TEST] Running main loop...', type: 'stdout' },
{ timestamp: Date.now() - 2000, message: '[TEST] Warning: Memory usage high', type: 'stderr' },
{ timestamp: Date.now() - 1000, message: '[TEST] Error: Unhandled exception occurred!', type: 'stderr' },
{ timestamp: Date.now() - 500, message: '[TEST] Fatal: Process crashing with exit code 42', type: 'stderr' }
];
// Test saving a crash log
await crashLogManager.saveCrashLog(
1 as any,
'test-process',
testLogs,
42,
null,
3,
1024 * 1024 * 50
);
// Check if crash log was created
const crashLogFiles = await fs.readdir(crashLogsDir).catch(() => []);
expect(crashLogFiles.length).toBeGreaterThan(0);
// Read and verify content
const crashLogFile = crashLogFiles[0];
const crashLogPath = plugins.path.join(crashLogsDir, crashLogFile);
const crashLogContent = await fs.readFile(crashLogPath, 'utf-8');
expect(crashLogContent).toInclude('CRASH REPORT');
expect(crashLogContent).toInclude('Exit Code: 42');
expect(crashLogContent).toInclude('Restart Attempt: 3/10');
expect(crashLogContent).toInclude('Memory Usage: 50 MB');
expect(crashLogContent).toInclude('Fatal: Process crashing');
});
tap.test('CrashLogManager should rotate old logs at 100 limit', async () => {
const crashLogManager = new CrashLogManager();
const crashLogsDir = plugins.path.join(paths.tspmDir, 'crashlogs');
// Clean up
try {
await fs.rm(crashLogsDir, { recursive: true, force: true });
} catch {}
const testLogs: IProcessLog[] = [
{ timestamp: Date.now(), message: '[TEST] Test log', type: 'stdout' }
];
// Create 105 crash logs to test rotation
for (let i = 1; i <= 105; i++) {
await crashLogManager.saveCrashLog(
i as any,
`test-process-${i}`,
testLogs,
i,
null,
1,
1024 * 1024 * 10
);
await new Promise(resolve => setTimeout(resolve, 10));
}
// Check that we have exactly 100 logs (rotation working)
const finalLogFiles = await fs.readdir(crashLogsDir);
expect(finalLogFiles.length).toEqual(100);
// Verify oldest logs were deleted
const hasFirstLog = finalLogFiles.some(f => f.includes('_1_test-process-1.log'));
expect(hasFirstLog).toBeFalse();
});
export default tap.start();

View File

@@ -0,0 +1,107 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import * as paths from '../ts/paths.js';
import * as fs from 'fs/promises';
import { execSync } from 'child_process';
// Test process that will crash
const CRASH_SCRIPT = `
setInterval(() => {
console.log('[test] Process is running...');
}, 1000);
setTimeout(() => {
console.error('[test] About to crash with non-zero exit code!');
process.exit(42);
}, 3000);
`;
tap.test('manual crash log test via CLI', async (tools) => {
const crashScriptPath = plugins.path.join(paths.tspmDir, 'test-crash-script.js');
const crashLogsDir = plugins.path.join(paths.tspmDir, 'crashlogs');
// Clean up any existing crash logs
try {
await fs.rm(crashLogsDir, { recursive: true, force: true });
} catch {}
// Write the crash script
await fs.writeFile(crashScriptPath, CRASH_SCRIPT);
// Stop any existing daemon
try {
execSync('tsx ts/cli.ts daemon stop', { stdio: 'pipe' });
} catch {}
await tools.delayFor(1000);
// Start the daemon
console.log('Starting daemon...');
try {
execSync('tsx ts/cli.ts daemon start', { stdio: 'pipe' });
} catch {}
await tools.delayFor(2000);
// Add a process that will crash
console.log('Adding crash test process...');
let addOutput: string;
try {
addOutput = execSync(`tsx ts/cli.ts add "node ${crashScriptPath}" --name crash-test`, { encoding: 'utf-8', stdio: 'pipe' });
} catch (e: any) {
addOutput = e.stdout || '';
}
console.log(addOutput);
// Extract process ID from output
const idMatch = addOutput.match(/Assigned ID: (\d+)/i)
|| addOutput.match(/id[:\s]+(\d+)/i);
if (!idMatch) {
console.log('Could not extract process ID, skipping rest of test');
// Clean up
try { execSync('tsx ts/cli.ts daemon stop', { stdio: 'pipe' }); } catch {}
await fs.unlink(crashScriptPath).catch(() => {});
return;
}
const processId = parseInt(idMatch[1]);
console.log(`Process ID: ${processId}`);
// Start the process
console.log('Starting process that will crash...');
try {
execSync(`tsx ts/cli.ts start ${processId}`, { stdio: 'pipe' });
} catch {}
// Wait for the process to crash (it crashes after 3 seconds)
console.log('Waiting for process to crash...');
await tools.delayFor(5000);
// Check if crash log was created
console.log('Checking for crash log...');
const crashLogFiles = await fs.readdir(crashLogsDir).catch(() => []);
console.log(`Found ${crashLogFiles.length} crash log files:`);
crashLogFiles.forEach(file => console.log(` - ${file}`));
expect(crashLogFiles.length).toBeGreaterThan(0);
// Find and verify crash log
const testCrashLog = crashLogFiles.find(file => file.includes('crash-test'));
if (testCrashLog) {
const crashLogPath = plugins.path.join(crashLogsDir, testCrashLog);
const crashLogContent = await fs.readFile(crashLogPath, 'utf-8');
console.log('\nCrash log content:');
console.log(crashLogContent);
expect(crashLogContent).toInclude('CRASH REPORT');
expect(crashLogContent).toInclude('Exit Code');
}
// Clean up
console.log('Cleaning up...');
try { execSync(`tsx ts/cli.ts delete ${processId}`, { stdio: 'pipe' }); } catch {}
try { execSync('tsx ts/cli.ts daemon stop', { stdio: 'pipe' }); } catch {}
await fs.unlink(crashScriptPath).catch(() => {});
});
export default tap.start();

202
test/test.crashlog.ts Normal file
View File

@@ -0,0 +1,202 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import * as paths from '../ts/paths.js';
import * as fs from 'fs/promises';
import { execSync } from 'child_process';
// Import tspm client
import { tspmIpcClient } from '../ts/client/tspm.ipcclient.js';
// Test process that will crash
const CRASH_SCRIPT = `
setInterval(() => {
console.log('[test] Process is running...');
}, 1000);
setTimeout(() => {
console.error('[test] About to crash with non-zero exit code!');
process.exit(42);
}, 3000);
`;
/**
* Helper to run a CLI command and capture output
*/
function runCli(cmd: string): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execSync(cmd, { encoding: 'utf-8', stdio: 'pipe' });
return { stdout, stderr: '', exitCode: 0 };
} catch (e: any) {
return {
stdout: e.stdout?.toString() || '',
stderr: e.stderr?.toString() || '',
exitCode: e.status ?? 1,
};
}
}
tap.test('should create crash logs when process crashes', async (tools) => {
const crashScriptPath = plugins.path.join(paths.tspmDir, 'test-crash-script.js');
const crashLogsDir = plugins.path.join(paths.tspmDir, 'crashlogs');
// Clean up any existing crash logs
try {
await fs.rm(crashLogsDir, { recursive: true, force: true });
} catch {}
// Write the crash script
await fs.writeFile(crashScriptPath, CRASH_SCRIPT);
// Stop any existing daemon first
runCli('tsx ts/cli.ts daemon stop');
await tools.delayFor(1000);
// Start the daemon
console.log('Starting daemon...');
const daemonResult = runCli('tsx ts/cli.ts daemon start');
console.log('Daemon start output:', daemonResult.stdout, daemonResult.stderr);
// Wait for daemon to be ready
await tools.delayFor(3000);
// Add a process that will crash
console.log('Adding crash test process...');
const addResult = runCli(`tsx ts/cli.ts add "node ${crashScriptPath}" --name crash-test`);
console.log('Add output:', addResult.stdout, addResult.stderr);
// Extract process ID from output
const idMatch = addResult.stdout.match(/Assigned ID:\s*(\d+)/);
if (!idMatch) {
console.log('Could not extract process ID from output, skipping integration test');
runCli('tsx ts/cli.ts daemon stop');
await fs.unlink(crashScriptPath).catch(() => {});
return;
}
const processId = parseInt(idMatch[1]);
console.log(`Process ID: ${processId}`);
// Start the process
console.log('Starting process that will crash...');
runCli(`tsx ts/cli.ts start ${processId}`);
// Wait for the process to crash (it crashes after 3 seconds)
console.log('Waiting for process to crash...');
await tools.delayFor(5000);
// Check if crash log was created
console.log('Checking for crash log...');
const crashLogFiles = await fs.readdir(crashLogsDir).catch(() => []);
console.log(`Found ${crashLogFiles.length} crash log files:`, crashLogFiles);
// Should have at least one crash log
expect(crashLogFiles.length).toBeGreaterThan(0);
// Find the crash log for our test process
const testCrashLog = crashLogFiles.find(file => file.includes('crash-test'));
expect(testCrashLog).toBeTruthy();
// Read and verify crash log content
const crashLogPath = plugins.path.join(crashLogsDir, testCrashLog!);
const crashLogContent = await fs.readFile(crashLogPath, 'utf-8');
console.log('Crash log content:');
console.log(crashLogContent);
// Verify crash log contains expected information
expect(crashLogContent).toInclude('CRASH REPORT');
expect(crashLogContent).toInclude('Exit Code');
expect(crashLogContent).toInclude('About to crash');
// Stop the process and daemon
console.log('Cleaning up...');
runCli(`tsx ts/cli.ts delete ${processId}`);
runCli('tsx ts/cli.ts daemon stop');
// Clean up test file
await fs.unlink(crashScriptPath).catch(() => {});
});
tap.test('should create crash logs when process is killed', async (tools) => {
const killScriptPath = plugins.path.join(paths.tspmDir, 'test-kill-script.js');
const crashLogsDir = plugins.path.join(paths.tspmDir, 'crashlogs');
// Write a script that runs indefinitely
const KILL_SCRIPT = `
setInterval(() => {
console.log('[test] Process is running and will be killed...');
}, 500);
`;
await fs.writeFile(killScriptPath, KILL_SCRIPT);
// Stop any existing daemon
runCli('tsx ts/cli.ts daemon stop');
await tools.delayFor(1000);
// Start the daemon
console.log('Starting daemon...');
runCli('tsx ts/cli.ts daemon start');
// Wait for daemon to be ready
await tools.delayFor(3000);
// Add a process that we'll kill
console.log('Adding kill test process...');
const addResult = runCli(`tsx ts/cli.ts add "node ${killScriptPath}" --name kill-test`);
console.log('Add output:', addResult.stdout, addResult.stderr);
// Extract process ID
const idMatch = addResult.stdout.match(/Assigned ID:\s*(\d+)/);
if (!idMatch) {
console.log('Could not extract process ID from output, skipping integration test');
runCli('tsx ts/cli.ts daemon stop');
await fs.unlink(killScriptPath).catch(() => {});
return;
}
const processId = parseInt(idMatch[1]);
// Start the process
console.log('Starting process to be killed...');
runCli(`tsx ts/cli.ts start ${processId}`);
// Wait for process to run a bit
await tools.delayFor(2000);
// Get the actual PID of the running process
const statusResult = runCli(`tsx ts/cli.ts describe ${processId}`);
const pidMatch = statusResult.stdout.match(/pid:\s+(\d+)/);
if (pidMatch) {
const pid = parseInt(pidMatch[1]);
console.log(`Killing process with PID ${pid}...`);
// Kill the process with SIGTERM
runCli(`kill -TERM ${pid}`);
// Wait for crash log to be created
await tools.delayFor(3000);
// Check for crash log
console.log('Checking for crash log from killed process...');
const crashLogFiles = await fs.readdir(crashLogsDir).catch(() => []);
const killCrashLog = crashLogFiles.find(file => file.includes('kill-test'));
if (killCrashLog) {
const crashLogPath = plugins.path.join(crashLogsDir, killCrashLog);
const crashLogContent = await fs.readFile(crashLogPath, 'utf-8');
console.log('Kill crash log content:');
console.log(crashLogContent);
expect(crashLogContent).toInclude('SIGTERM');
}
}
// Clean up
console.log('Cleaning up...');
runCli(`tsx ts/cli.ts delete ${processId}`);
runCli('tsx ts/cli.ts daemon stop');
await fs.unlink(killScriptPath).catch(() => {});
});
export default tap.start();

View File

@@ -5,6 +5,7 @@ import * as fs from 'fs/promises';
import * as os from 'os';
import { spawn } from 'child_process';
import { tspmIpcClient, TspmIpcClient } from '../ts/client/tspm.ipcclient.js';
import { toProcessId } from '../ts/shared/protocol/id.js';
// Helper to ensure daemon is stopped before tests
async function ensureDaemonStopped() {
@@ -160,7 +161,7 @@ tap.test('Process management through daemon', async (tools) => {
// Test 2: Start a test process
const testConfig: tspm.IProcessConfig = {
id: 'test-echo',
id: toProcessId(1001),
name: 'Test Echo Process',
command: 'echo "Test process"',
projectDir: process.cwd(),
@@ -172,7 +173,7 @@ tap.test('Process management through daemon', async (tools) => {
config: testConfig,
});
console.log('Start response:', startResponse);
expect(startResponse.processId).toEqual('test-echo');
expect(startResponse.processId).toEqual(1001);
expect(startResponse.status).toBeDefined();
// Test 3: List processes (should have one process)
@@ -180,27 +181,27 @@ tap.test('Process management through daemon', async (tools) => {
console.log('List after start:', listResponse);
expect(listResponse.processes.length).toBeGreaterThanOrEqual(1);
const procInfo = listResponse.processes.find((p) => p.id === 'test-echo');
const procInfo = listResponse.processes.find((p) => p.id === toProcessId(1001));
expect(procInfo).toBeDefined();
expect(procInfo?.id).toEqual('test-echo');
expect(procInfo?.id).toEqual(1001);
// Test 4: Describe the process
const describeResponse = await tspmIpcClient.request('describe', {
id: 'test-echo',
id: toProcessId(1001),
});
console.log('Describe:', describeResponse);
expect(describeResponse.processInfo).toBeDefined();
expect(describeResponse.config).toBeDefined();
expect(describeResponse.config.id).toEqual('test-echo');
expect(describeResponse.config.id).toEqual(1001);
// Test 5: Stop the process
const stopResponse = await tspmIpcClient.request('stop', { id: 'test-echo' });
const stopResponse = await tspmIpcClient.request('stop', { id: toProcessId(1001) });
console.log('Stop response:', stopResponse);
expect(stopResponse.success).toEqual(true);
// Test 6: Delete the process
const deleteResponse = await tspmIpcClient.request('delete', {
id: 'test-echo',
id: toProcessId(1001),
});
console.log('Delete response:', deleteResponse);
expect(deleteResponse.success).toEqual(true);
@@ -208,9 +209,7 @@ tap.test('Process management through daemon', async (tools) => {
// Test 7: Verify process is gone
listResponse = await tspmIpcClient.request('list', {});
console.log('List after delete:', listResponse);
const deletedProcess = listResponse.processes.find(
(p) => p.id === 'test-echo',
);
const deletedProcess = listResponse.processes.find((p) => p.id === toProcessId(1001));
expect(deletedProcess).toBeUndefined();
// Cleanup: stop daemon
@@ -241,7 +240,7 @@ tap.test('Batch operations through daemon', async (tools) => {
// Add multiple test processes
const testConfigs: tspm.IProcessConfig[] = [
{
id: 'batch-test-1',
id: toProcessId(1101),
name: 'Batch Test 1',
command: 'echo "Process 1"',
projectDir: process.cwd(),
@@ -249,7 +248,7 @@ tap.test('Batch operations through daemon', async (tools) => {
autorestart: false,
},
{
id: 'batch-test-2',
id: toProcessId(1102),
name: 'Batch Test 2',
command: 'echo "Process 2"',
projectDir: process.cwd(),
@@ -308,7 +307,7 @@ tap.test('Daemon error handling', async (tools) => {
// Test 1: Try to stop non-existent process
try {
await tspmIpcClient.request('stop', { id: 'non-existent-process' });
await tspmIpcClient.request('stop', { id: toProcessId(99999) });
expect(false).toEqual(true); // Should not reach here
} catch (error) {
expect(error.message).toInclude('Failed to stop process');
@@ -316,7 +315,7 @@ tap.test('Daemon error handling', async (tools) => {
// Test 2: Try to describe non-existent process
try {
await tspmIpcClient.request('describe', { id: 'non-existent-process' });
await tspmIpcClient.request('describe', { id: toProcessId(99999) });
expect(false).toEqual(true); // Should not reach here
} catch (error) {
expect(error.message).toInclude('not found');
@@ -324,7 +323,7 @@ tap.test('Daemon error handling', async (tools) => {
// Test 3: Try to restart non-existent process
try {
await tspmIpcClient.request('restart', { id: 'non-existent-process' });
await tspmIpcClient.request('restart', { id: toProcessId(99999) });
expect(false).toEqual(true); // Should not reach here
} catch (error) {
expect(error.message).toInclude('Failed to restart process');

View File

@@ -1,5 +1,6 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as tspm from '../ts/index.js';
import { toProcessId } from '../ts/shared/protocol/id.js';
import { join } from 'path';
// Basic module import test
@@ -51,7 +52,7 @@ async function exampleUsingIpcClient() {
// Start a process using the request method
await client.request('start', {
config: {
id: 'web-server',
id: toProcessId(2001),
name: 'Web Server',
projectDir: '/path/to/web/project',
command: 'npm run serve',
@@ -65,7 +66,7 @@ async function exampleUsingIpcClient() {
// Start another process
await client.request('start', {
config: {
id: 'api-server',
id: toProcessId(2002),
name: 'API Server',
projectDir: '/path/to/api/project',
command: 'npm run api',
@@ -80,13 +81,13 @@ async function exampleUsingIpcClient() {
// Get logs from a process
const logs = await client.request('getLogs', {
id: 'web-server',
id: toProcessId(2001),
lines: 20,
});
console.log('Web server logs:', logs.logs);
// Stop a process
await client.request('stop', { id: 'api-server' });
await client.request('stop', { id: toProcessId(2002) });
// Handle graceful shutdown
process.on('SIGINT', async () => {

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@git.zone/tspm',
version: '5.3.1',
version: '5.10.4',
description: 'a no fuzz process manager'
}

View File

@@ -39,6 +39,7 @@ export function registerDefaultCommand(smartcli: plugins.smartcli.Smartcli) {
);
console.log(' daemon stop Stop the daemon');
console.log(' daemon status Show daemon status');
console.log(' stats Show daemon + process stats');
console.log(
'\nUse tspm [command] --help for more information about a command.',
);

View File

@@ -20,6 +20,7 @@ export function registerAddCommand(smartcli: plugins.smartcli.Smartcli) {
console.log(' --watch Watch for file changes');
console.log(' --watch-paths <paths> Comma-separated paths');
console.log(' --autorestart Auto-restart on crash (default true)');
console.log(' -i, --interactive Enter interactive edit mode after adding');
return;
}
@@ -29,6 +30,9 @@ export function registerAddCommand(smartcli: plugins.smartcli.Smartcli) {
? parseMemoryString(argvArg.memory)
: 512 * 1024 * 1024;
// Check for interactive flag
const isInteractive = argvArg.i || argvArg.interactive;
// Resolve .ts single-file execution via tsx if needed
const parts = script.split(' ');
const first = parts[0];
@@ -112,6 +116,12 @@ export function registerAddCommand(smartcli: plugins.smartcli.Smartcli) {
console.log('✓ Added');
console.log(` Assigned ID: ${response.id}`);
// If interactive flag is set, enter edit mode
if (isInteractive) {
const { interactiveEditProcess } = await import('../../helpers/interactive-edit.js');
await interactiveEditProcess(response.id);
}
},
{ actionLabel: 'add process config' },
);

View File

@@ -16,58 +16,12 @@ export function registerEditCommand(smartcli: plugins.smartcli.Smartcli) {
return;
}
// Resolve and load current config
// Resolve the target to get the process ID
const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
const { config } = await tspmIpcClient.request('describe', { id: resolved.id });
// Interactive editing is temporarily disabled - needs smartinteract API update
console.log('Interactive editing is temporarily disabled.');
console.log('Current configuration:');
console.log(` Name: ${config.name}`);
console.log(` Command: ${config.command}`);
console.log(` Directory: ${config.projectDir}`);
console.log(` Memory: ${formatMemory(config.memoryLimitBytes)}`);
console.log(` Auto-restart: ${config.autorestart}`);
console.log(` Watch: ${config.watch ? 'enabled' : 'disabled'}`);
// For now, just update environment variables to current
const essentialEnvVars: NodeJS.ProcessEnv = {
PATH: process.env.PATH || '',
HOME: process.env.HOME,
USER: process.env.USER,
SHELL: process.env.SHELL,
LANG: process.env.LANG,
LC_ALL: process.env.LC_ALL,
// Node.js specific
NODE_ENV: process.env.NODE_ENV,
NODE_PATH: process.env.NODE_PATH,
// npm/pnpm/yarn paths
npm_config_prefix: process.env.npm_config_prefix,
// Include any TSPM_ prefixed vars
...Object.fromEntries(
Object.entries(process.env).filter(([key]) => key.startsWith('TSPM_'))
),
};
// Remove undefined values
Object.keys(essentialEnvVars).forEach(key => {
if (essentialEnvVars[key] === undefined) {
delete essentialEnvVars[key];
}
});
// Update environment variables
const updates = {
env: { ...(config.env || {}), ...essentialEnvVars }
};
const updateResponse = await tspmIpcClient.request('update', {
id: resolved.id,
updates,
});
console.log('✓ Environment variables updated');
console.log(' Process configuration updated successfully');
// Use the shared interactive edit function
const { interactiveEditProcess } = await import('../../helpers/interactive-edit.js');
await interactiveEditProcess(resolved.id);
},
{ actionLabel: 'edit process config' },
);

View File

@@ -14,19 +14,21 @@ export function registerListCommand(smartcli: plugins.smartcli.Smartcli) {
const processes = response.processes;
if (processes.length === 0) {
console.log('No processes running.');
console.log('No processes configured.');
console.log('Use "tspm add <command>" to add one, e.g.:');
console.log(' tspm add "pnpm start"');
return;
}
console.log('Process List:');
console.log(
'┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────┐',
'┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────┬─────────┐',
);
console.log(
'│ ID │ Name │ Status │ PID │ Memory │ Restarts │',
'│ ID │ Name │ Status │ PID │ Memory │ CPU │ Restarts │',
);
console.log(
'├─────────┼─────────────┼───────────┼───────────┼──────────┼──────────┤',
'├─────────┼─────────────┼───────────┼───────────┼──────────┼──────────┼──────────┤',
);
for (const proc of processes) {
@@ -38,13 +40,18 @@ export function registerListCommand(smartcli: plugins.smartcli.Smartcli) {
: '\x1b[33m';
const resetColor = '\x1b[0m';
const cpuStr = typeof proc.cpu === 'number' && isFinite(proc.cpu)
? `${proc.cpu.toFixed(1)}%`
: '-';
// Name is not part of IProcessInfo; show ID as placeholder for now
const nameDisplay = String(proc.id);
console.log(
`${pad(String(proc.id), 7)}${pad(String(proc.id), 11)}${statusColor}${pad(proc.status, 9)}${resetColor}${pad((proc.pid || '-').toString(), 9)}${pad(formatMemory(proc.memory), 8)}${pad(proc.restarts.toString(), 8)}`,
`${pad(String(proc.id), 7)}${pad(nameDisplay, 11)}${statusColor}${pad(proc.status, 9)}${resetColor}${pad((proc.pid || '-').toString(), 9)}${pad(formatMemory(proc.memory), 8)} ${pad(cpuStr, 8)} ${pad(proc.restarts.toString(), 8)}`,
);
}
console.log(
'└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┘',
'└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┴──────────┘',
);
},
{ actionLabel: 'list processes' },

View File

@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.js';
import { tspmIpcClient } from '../../../client/tspm.ipcclient.js';
import type { CliArguments } from '../../types.js';
import { registerIpcCommand } from '../../registration/index.js';
import { getBool, getNumber } from '../../helpers/argv.js';
import { getBool, getNumber, getString } from '../../helpers/argv.js';
import { formatLog } from '../../helpers/formatting.js';
import { withStreamingLifecycle } from '../../helpers/lifecycle.js';
@@ -17,22 +17,60 @@ export function registerLogsCommand(smartcli: plugins.smartcli.Smartcli) {
console.log('Usage: tspm logs <id | id:N | name:LABEL> [options]');
console.log('\nOptions:');
console.log(' --lines <n> Number of lines to show (default: 50)');
console.log(' --since <dur> Only show logs since duration (e.g., 10m, 2h, 1d)');
console.log(' --stderr-only Only show stderr logs');
console.log(' --stdout-only Only show stdout logs');
console.log(' --ndjson Output each log as JSON line');
console.log(' --follow Stream logs in real-time (like tail -f)');
return;
}
const lines = getNumber(argvArg, 'lines', 50);
const follow = getBool(argvArg, 'follow', 'f');
const sinceSpec = getString(argvArg, 'since');
const stderrOnly = getBool(argvArg, 'stderr-only');
const stdoutOnly = getBool(argvArg, 'stdout-only');
const ndjson = getBool(argvArg, 'ndjson');
const parseDuration = (spec?: string): number | undefined => {
if (!spec) return undefined;
const m = spec.trim().match(/^(\d+)(ms|s|m|h|d)?$/i);
if (!m) return undefined;
const val = Number(m[1]);
const unit = (m[2] || 'm').toLowerCase();
const mult = unit === 'ms' ? 1 : unit === 's' ? 1000 : unit === 'm' ? 60000 : unit === 'h' ? 3600000 : 86400000;
return Date.now() - val * mult;
};
const sinceTime = parseDuration(sinceSpec);
const typesFilter: Array<'stdout' | 'stderr' | 'system'> | undefined =
stderrOnly && !stdoutOnly
? ['stderr']
: stdoutOnly && !stderrOnly
? ['stdout']
: undefined; // all
const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
const id = resolved.id;
const response = await tspmIpcClient.request('getLogs', { id, lines });
const response = await tspmIpcClient.request('getLogs', { id, lines: sinceTime ? 0 : lines });
if (!follow) {
// One-shot mode - auto-disconnect handled by registerIpcCommand
console.log(`Logs for process: ${id} (last ${lines} lines)`);
const filtered = response.logs.filter((l) => {
if (typesFilter && !typesFilter.includes(l.type)) return false;
if (sinceTime && new Date(l.timestamp).getTime() < sinceTime) return false;
return true;
});
console.log(`Logs for process: ${id} (${sinceTime ? 'since ' + new Date(sinceTime).toLocaleString() : 'last ' + lines + ' lines'})`);
console.log('─'.repeat(60));
for (const log of response.logs) {
for (const log of filtered) {
if (ndjson) {
console.log(
JSON.stringify({
...log,
timestamp: new Date(log.timestamp).getTime(),
}),
);
} else {
const timestamp = new Date(log.timestamp).toLocaleTimeString();
const prefix =
log.type === 'stdout'
@@ -42,6 +80,7 @@ export function registerLogsCommand(smartcli: plugins.smartcli.Smartcli) {
: '[SYS]';
console.log(`${timestamp} ${prefix} ${log.message}`);
}
}
return;
}
@@ -49,8 +88,20 @@ export function registerLogsCommand(smartcli: plugins.smartcli.Smartcli) {
console.log(`Logs for process: ${resolved.name || id} (streaming...)`);
console.log('─'.repeat(60));
// Prepare backlog printing state and stream handler
let lastSeq = 0;
for (const log of response.logs) {
let lastRunId: string | undefined = undefined;
const printLog = (log: any) => {
if (typesFilter && !typesFilter.includes(log.type)) return;
if (sinceTime && new Date(log.timestamp).getTime() < sinceTime) return;
if (ndjson) {
console.log(
JSON.stringify({
...log,
timestamp: new Date(log.timestamp).getTime(),
}),
);
} else {
const timestamp = new Date(log.timestamp).toLocaleTimeString();
const prefix =
log.type === 'stdout'
@@ -59,26 +110,61 @@ export function registerLogsCommand(smartcli: plugins.smartcli.Smartcli) {
? '[ERR]'
: '[SYS]';
console.log(`${timestamp} ${prefix} ${log.message}`);
}
};
// Print initial backlog (already fetched via getLogs)
for (const log of response.logs) {
printLog(log);
if (log.seq !== undefined) lastSeq = Math.max(lastSeq, log.seq);
if ((log as any).runId) lastRunId = (log as any).runId;
}
await withStreamingLifecycle(
async () => {
await tspmIpcClient.subscribe(id, (log: any) => {
// Request additional backlog delivered as incremental messages to avoid large payloads
try {
const disposeBacklog = tspmIpcClient.onBacklogTopic(id, (log: any) => {
if (log.runId && log.runId !== lastRunId) {
console.log(`[INFO] Detected process restart (runId changed).`);
lastSeq = -1;
lastRunId = log.runId;
}
if (log.seq !== undefined && log.seq <= lastSeq) return;
if (log.seq !== undefined && log.seq > lastSeq + 1) {
console.log(
`[WARNING] Log gap detected: expected seq ${lastSeq + 1}, got ${log.seq}`,
);
}
const timestamp = new Date(log.timestamp).toLocaleTimeString();
const prefix =
log.type === 'stdout'
? '[OUT]'
: log.type === 'stderr'
? '[ERR]'
: '[SYS]';
console.log(`${timestamp} ${prefix} ${log.message}`);
printLog({ ...log, timestamp: new Date(log.timestamp) });
if (log.seq !== undefined) lastSeq = log.seq;
});
await tspmIpcClient.requestLogsBacklogStream(id, { lines: sinceTime ? undefined : lines, sinceTime, types: typesFilter });
// Dispose backlog handler after a short grace (backlog is finite)
setTimeout(() => disposeBacklog(), 10000);
} catch {}
await withStreamingLifecycle(
async () => {
// Optional: debug subscribers if requested via env (hidden)
if (process.env.TSPM_DEBUG === 'true') {
try {
const subInfo = await tspmIpcClient.request('logs:subscribers' as any, { id });
console.log(`[DEBUG] Subscribers for logs.${id}: ${subInfo.count} (${(subInfo.subscribers||[]).join(',')})`);
} catch {}
}
await tspmIpcClient.subscribe(id, (log: any) => {
// Reset sequence if runId changed (e.g., process restarted)
if (log.runId && log.runId !== lastRunId) {
console.log(`[INFO] Detected process restart (runId changed).`);
lastSeq = -1;
lastRunId = log.runId;
}
if (log.seq !== undefined && log.seq <= lastSeq) return;
if (log.seq !== undefined && log.seq > lastSeq + 1) {
console.log(
`[WARNING] Log gap detected: expected seq ${lastSeq + 1}, got ${log.seq}`,
);
}
printLog(log);
if (log.seq !== undefined) lastSeq = log.seq;
});
},

66
ts/cli/commands/stats.ts Normal file
View File

@@ -0,0 +1,66 @@
import * as plugins from '../plugins.js';
import { tspmIpcClient } from '../../client/tspm.ipcclient.js';
import type { CliArguments } from '../types.js';
import { registerIpcCommand } from '../registration/index.js';
import { pad } from '../helpers/formatting.js';
import { formatMemory } from '../helpers/memory.js';
export function registerStatsCommand(smartcli: plugins.smartcli.Smartcli) {
registerIpcCommand(
smartcli,
'stats',
async (_argvArg: CliArguments) => {
// Daemon status
const status = await tspmIpcClient.request('daemon:status', {});
console.log('TSPM Daemon:');
console.log('─'.repeat(60));
console.log(`Version: ${status.version || 'unknown'}`);
console.log(`PID: ${status.pid}`);
console.log(`Uptime: ${Math.floor((status.uptime || 0) / 1000)}s`);
console.log(`Processes: ${status.processCount}`);
if (typeof status.memoryUsage === 'number') {
console.log(`Memory: ${formatMemory(status.memoryUsage)}`);
}
if (typeof status.cpuUsage === 'number') {
console.log(`CPU (user): ${status.cpuUsage.toFixed(3)}s`);
}
if ((status as any).paths) {
const pathsInfo = (status as any).paths as { tspmDir?: string; socketPath?: string; pidFile?: string };
console.log(`tspmDir: ${pathsInfo.tspmDir || '-'}`);
console.log(`Socket: ${pathsInfo.socketPath || '-'}`);
console.log(`PID File: ${pathsInfo.pidFile || '-'}`);
}
if ((status as any).configs) {
const cfg = (status as any).configs as { processConfigs?: number };
console.log(`Configs: ${cfg.processConfigs ?? 0}`);
}
if ((status as any).logsInMemory) {
const lm = (status as any).logsInMemory as { totalCount: number; totalBytes: number };
console.log(`Logs (mem): ${lm.totalCount} entries, ${formatMemory(lm.totalBytes)}`);
}
console.log('');
// Process list (reuse list view with CPU column)
const response = await tspmIpcClient.request('list', {});
const processes = response.processes;
console.log('Process List:');
console.log('┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────┬─────────┐');
console.log('│ ID │ Name │ Status │ PID │ Memory │ CPU │ 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';
const cpuStr = typeof proc.cpu === 'number' && isFinite(proc.cpu) ? `${proc.cpu.toFixed(1)}%` : '-';
const nameDisplay = String(proc.id); // name not carried in IProcessInfo
console.log(
`${pad(String(proc.id), 7)}${pad(nameDisplay, 11)}${statusColor}${pad(proc.status, 9)}${resetColor}${pad((proc.pid || '-').toString(), 9)}${pad(formatMemory(proc.memory), 8)}${pad(cpuStr, 8)}${pad(proc.restarts.toString(), 8)}`,
);
}
console.log('└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┴──────────┘');
},
{ actionLabel: 'get daemon stats' },
);
}

View File

@@ -0,0 +1,164 @@
import * as plugins from '../plugins.js';
import { tspmIpcClient } from '../../client/tspm.ipcclient.js';
import { formatMemory, parseMemoryString } from './memory.js';
export async function interactiveEditProcess(processId: number): Promise<void> {
// Load current config
const { config } = await tspmIpcClient.request('describe', { id: processId as any });
// Create interactive prompts for editing
const smartInteract = new plugins.smartinteract.SmartInteract([
{
name: 'name',
type: 'input',
message: 'Process name:',
default: config.name,
validate: (input: string) => {
return input && input.trim() !== '';
}
},
{
name: 'command',
type: 'input',
message: 'Command to execute:',
default: config.command,
validate: (input: string) => {
return input && input.trim() !== '';
}
},
{
name: 'projectDir',
type: 'input',
message: 'Working directory:',
default: config.projectDir,
validate: (input: string) => {
return input && input.trim() !== '';
}
},
{
name: 'memoryLimit',
type: 'input',
message: 'Memory limit (e.g., 512M, 1G):',
default: formatMemory(config.memoryLimitBytes),
validate: (input: string) => {
const parsed = parseMemoryString(input);
return parsed !== null;
}
},
{
name: 'autorestart',
type: 'confirm',
message: 'Enable auto-restart on failure?',
default: config.autorestart
},
{
name: 'watch',
type: 'confirm',
message: 'Enable file watching for auto-restart?',
default: config.watch || false
},
{
name: 'updateEnv',
type: 'confirm',
message: 'Update environment variables to current environment?',
default: true
}
]);
console.log('\n📝 Edit Process Configuration');
console.log(` Process ID: ${processId}`);
console.log(' (Press Enter to keep current values)\n');
// Run the interactive prompts
const answerBucket = await smartInteract.runQueue();
// Get answers from the bucket
const name = answerBucket.getAnswerFor('name');
const command = answerBucket.getAnswerFor('command');
const projectDir = answerBucket.getAnswerFor('projectDir');
const memoryLimit = answerBucket.getAnswerFor('memoryLimit');
const autorestart = answerBucket.getAnswerFor('autorestart');
const watch = answerBucket.getAnswerFor('watch');
const updateEnv = answerBucket.getAnswerFor('updateEnv');
// Prepare updates object
const updates: any = {};
// Check what has changed
if (name !== config.name) {
updates.name = name;
}
if (command !== config.command) {
updates.command = command;
}
if (projectDir !== config.projectDir) {
updates.projectDir = projectDir;
}
const newMemoryBytes = parseMemoryString(memoryLimit);
if (newMemoryBytes !== config.memoryLimitBytes) {
updates.memoryLimitBytes = newMemoryBytes;
}
if (autorestart !== config.autorestart) {
updates.autorestart = autorestart;
}
if (watch !== config.watch) {
updates.watch = watch;
}
// Handle environment variables update if requested
if (updateEnv) {
const essentialEnvVars: NodeJS.ProcessEnv = {
PATH: process.env.PATH || '',
HOME: process.env.HOME,
USER: process.env.USER,
SHELL: process.env.SHELL,
LANG: process.env.LANG,
LC_ALL: process.env.LC_ALL,
// Node.js specific
NODE_ENV: process.env.NODE_ENV,
NODE_PATH: process.env.NODE_PATH,
// npm/pnpm/yarn paths
npm_config_prefix: process.env.npm_config_prefix,
// Include any TSPM_ prefixed vars
...Object.fromEntries(
Object.entries(process.env).filter(([key]) => key.startsWith('TSPM_'))
),
};
// Remove undefined values
Object.keys(essentialEnvVars).forEach(key => {
if (essentialEnvVars[key] === undefined) {
delete essentialEnvVars[key];
}
});
updates.env = { ...(config.env || {}), ...essentialEnvVars };
}
// Only update if there are changes
if (Object.keys(updates).length === 0) {
console.log('\n✓ No changes made');
return;
}
// Send updates to daemon
await tspmIpcClient.request('update', {
id: processId as any,
updates,
});
// Display what was updated
console.log('\n✓ Process configuration updated successfully');
if (updates.name) console.log(` Name: ${updates.name}`);
if (updates.command) console.log(` Command: ${updates.command}`);
if (updates.projectDir) console.log(` Directory: ${updates.projectDir}`);
if (updates.memoryLimitBytes) console.log(` Memory limit: ${formatMemory(updates.memoryLimitBytes)}`);
if (updates.autorestart !== undefined) console.log(` Auto-restart: ${updates.autorestart}`);
if (updates.watch !== undefined) console.log(` Watch: ${updates.watch ? 'enabled' : 'disabled'}`);
if (updateEnv) console.log(' Environment variables: updated');
}

View File

@@ -2,6 +2,7 @@ import * as plugins from './plugins.js';
import { tspmIpcClient } from '../client/tspm.ipcclient.js';
import * as paths from '../paths.js';
import { Logger, LogLevel } from '../shared/common/utils.errorhandler.js';
import { TspmServiceManager } from '../client/tspm.servicemanager.js';
// Import command registration functions
import { registerDefaultCommand } from './commands/default.js';
@@ -19,6 +20,7 @@ import { registerStartAllCommand } from './commands/batch/start-all.js';
import { registerStopAllCommand } from './commands/batch/stop-all.js';
import { registerRestartAllCommand } from './commands/batch/restart-all.js';
import { registerDaemonCommand } from './commands/daemon/index.js';
import { registerStatsCommand } from './commands/stats.js';
import { registerEnableCommand } from './commands/service/enable.js';
import { registerDisableCommand } from './commands/service/disable.js';
import { registerResetCommand } from './commands/reset.js';
@@ -51,6 +53,38 @@ export const run = async (): Promise<void> => {
console.log(
`Daemon: running v${status.version || 'unknown'} (pid ${status.pid})`,
);
// If versions mismatch, offer to refresh the systemd service
if (status.version && status.version !== cliVersion) {
console.log('\nVersion mismatch detected:');
console.log(` CLI: v${cliVersion}`);
console.log(` Daemon: v${status.version}`);
console.log(
'\nThis can happen after upgrading tspm. The systemd service may still point to an older version.\n' +
'You can refresh the service (equivalent to "tspm disable" then "tspm enable").',
);
// Ask the user for confirmation
const confirm = await plugins.smartinteract.SmartInteract.getCliConfirmation(
'Refresh the systemd service now?',
true,
);
if (confirm) {
try {
const sm = new TspmServiceManager();
console.log('Refreshing TSPM system service...');
await sm.disableService();
await sm.enableService();
console.log('✓ Service refreshed. Daemon restarted via systemd.');
} catch (err: any) {
console.error(
'Failed to refresh service automatically. You can try manually:\n tspm disable && tspm enable',
);
console.error(err?.message || String(err));
}
} else {
console.log('Skipped service refresh.');
}
}
} else {
console.log('Daemon: not running');
}
@@ -84,6 +118,7 @@ export const run = async (): Promise<void> => {
// Daemon commands
registerDaemonCommand(smartcliInstance);
registerStatsCommand(smartcliInstance);
// Service commands
registerEnableCommand(smartcliInstance);

View File

@@ -1,6 +1,7 @@
// Minimal plugin set for lightweight client startup
import * as path from 'node:path';
import * as smartdaemon from '@push.rocks/smartdaemon';
import * as smartipc from '@push.rocks/smartipc';
export { path, smartipc };
export { path, smartdaemon, smartipc };

View File

@@ -17,6 +17,9 @@ export class TspmIpcClient {
private socketPath: string;
private daemonPidFile: string;
private isConnected: boolean = false;
// Store event handlers for cleanup
private heartbeatTimeoutHandler?: () => void;
private markDisconnectedHandler?: () => void;
constructor() {
this.socketPath = plugins.path.join(paths.tspmDir, 'tspm.sock');
@@ -74,20 +77,21 @@ export class TspmIpcClient {
this.isConnected = true;
// Handle heartbeat timeouts gracefully
this.ipcClient.on('heartbeatTimeout', () => {
this.heartbeatTimeoutHandler = () => {
console.warn('Heartbeat timeout detected, connection may be degraded');
this.isConnected = false;
});
};
this.ipcClient.on('heartbeatTimeout', this.heartbeatTimeoutHandler);
// Reflect connection lifecycle on the client state
const markDisconnected = () => {
this.markDisconnectedHandler = () => {
this.isConnected = false;
};
// Common lifecycle events
this.ipcClient.on('disconnect', markDisconnected as any);
this.ipcClient.on('close', markDisconnected as any);
this.ipcClient.on('end', markDisconnected as any);
this.ipcClient.on('error', markDisconnected as any);
this.ipcClient.on('disconnect', this.markDisconnectedHandler as any);
this.ipcClient.on('close', this.markDisconnectedHandler as any);
this.ipcClient.on('end', this.markDisconnectedHandler as any);
this.ipcClient.on('error', this.markDisconnectedHandler as any);
// connected
} catch (error) {
@@ -103,6 +107,21 @@ export class TspmIpcClient {
*/
public async disconnect(): Promise<void> {
if (this.ipcClient) {
// Remove event listeners before disconnecting
if (this.heartbeatTimeoutHandler) {
this.ipcClient.removeListener('heartbeatTimeout', this.heartbeatTimeoutHandler);
}
if (this.markDisconnectedHandler) {
this.ipcClient.removeListener('disconnect', this.markDisconnectedHandler as any);
this.ipcClient.removeListener('close', this.markDisconnectedHandler as any);
this.ipcClient.removeListener('end', this.markDisconnectedHandler as any);
this.ipcClient.removeListener('error', this.markDisconnectedHandler as any);
}
// Clear handler references
this.heartbeatTimeoutHandler = undefined;
this.markDisconnectedHandler = undefined;
await this.ipcClient.disconnect();
this.ipcClient = null;
this.isConnected = false;
@@ -160,6 +179,55 @@ export class TspmIpcClient {
await this.ipcClient.subscribe(topic, handler);
}
/**
* Request backlog logs as a stream from the daemon.
* The actual stream will be delivered via the 'stream' event.
*/
public async requestLogsBacklogStream(
processId: ProcessId | number | string,
opts: { lines?: number; sinceTime?: number; types?: Array<'stdout' | 'stderr' | 'system'> } = {},
): Promise<void> {
if (!this.ipcClient || !this.isConnected) {
throw new Error('Not connected to daemon');
}
const id = toProcessId(processId);
await this.request('logs:subscribe' as any, {
id,
lines: opts.lines,
sinceTime: opts.sinceTime,
types: opts.types,
} as any);
}
/**
* Register a handler for incoming streams (e.g., backlog logs)
*/
public onStream(
handler: (info: any, readable: NodeJS.ReadableStream) => void,
): void {
if (!this.ipcClient) throw new Error('Not connected to daemon');
// smartipc emits 'stream' with (info, readable)
(this.ipcClient as any).on('stream', handler);
}
/**
* Register a temporary handler for backlog topic messages for a specific process
*/
public onBacklogTopic(
processId: ProcessId | number | string,
handler: (log: any) => void,
): () => void {
if (!this.ipcClient) throw new Error('Not connected to daemon');
const id = toProcessId(processId);
const topicType = `topic:logs.backlog.${id}`;
(this.ipcClient as any).onMessage(topicType, handler);
return () => {
try {
(this.ipcClient as any).messageHandlers?.delete?.(topicType);
} catch {}
};
}
/**
* Unsubscribe from log updates for a specific process
*/

View File

@@ -1,4 +1,4 @@
import * as plugins from '../plugins.js';
import * as plugins from './plugins.js';
import * as paths from '../paths.js';
/**

View File

@@ -0,0 +1,270 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import type { IProcessLog } from '../shared/protocol/ipc.types.js';
import type { ProcessId } from '../shared/protocol/id.js';
const smartfs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
/**
* Manages crash log storage for failed processes
*/
export class CrashLogManager {
private crashLogsDir: string;
private readonly MAX_CRASH_LOGS = 100;
private readonly MAX_LOG_SIZE_BYTES = 1024 * 1024; // 1MB
constructor() {
this.crashLogsDir = plugins.path.join(paths.tspmDir, 'crashlogs');
}
/**
* Save a crash log for a failed process
*/
public async saveCrashLog(
processId: ProcessId,
processName: string,
logs: IProcessLog[],
exitCode: number | null,
signal: string | null,
restartCount: number,
memoryUsage?: number
): Promise<void> {
try {
// Ensure directory exists
await this.ensureCrashLogsDir();
// Generate filename with timestamp
const timestamp = new Date();
const dateStr = this.formatDate(timestamp);
const sanitizedName = this.sanitizeFilename(processName);
const filename = `${dateStr}_${processId}_${sanitizedName}.log`;
const filepath = plugins.path.join(this.crashLogsDir, filename);
// Get recent logs that fit within size limit
const recentLogs = this.getRecentLogs(logs, this.MAX_LOG_SIZE_BYTES);
// Create crash report
const crashReport = this.formatCrashReport({
processId,
processName,
timestamp,
exitCode,
signal,
restartCount,
memoryUsage,
logs: recentLogs
});
// Write crash log
await smartfs.file(filepath).encoding('utf8').write(crashReport);
// Rotate old logs if needed
await this.rotateOldLogs();
console.log(`Crash log saved: ${filename}`);
} catch (error) {
console.error(`Failed to save crash log for process ${processId}:`, error);
}
}
/**
* Format date for filename: YYYY-MM-DD_HH-mm-ss
*/
private formatDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`;
}
/**
* Sanitize process name for use in filename
*/
private sanitizeFilename(name: string): string {
// Replace problematic characters with underscore
return name
.replace(/[^a-zA-Z0-9-_]/g, '_')
.replace(/_+/g, '_')
.substring(0, 50); // Limit length
}
/**
* Get recent logs that fit within the size limit
*/
private getRecentLogs(logs: IProcessLog[], maxBytes: number): IProcessLog[] {
if (logs.length === 0) return [];
// Start from the end and work backwards
const recentLogs: IProcessLog[] = [];
let currentSize = 0;
for (let i = logs.length - 1; i >= 0; i--) {
const log = logs[i];
const logSize = this.estimateLogSize(log);
if (currentSize + logSize > maxBytes && recentLogs.length > 0) {
// Would exceed limit, stop adding
break;
}
recentLogs.unshift(log);
currentSize += logSize;
}
return recentLogs;
}
/**
* Estimate size of a log entry in bytes
*/
private estimateLogSize(log: IProcessLog): number {
// Format: [timestamp] [type] message\n
const formatted = `[${new Date(log.timestamp).toISOString()}] [${log.type}] ${log.message}\n`;
return Buffer.byteLength(formatted, 'utf8');
}
/**
* Format a crash report with metadata and logs
*/
private formatCrashReport(data: {
processId: ProcessId;
processName: string;
timestamp: Date;
exitCode: number | null;
signal: string | null;
restartCount: number;
memoryUsage?: number;
logs: IProcessLog[];
}): string {
const lines: string[] = [
'================================================================================',
'TSPM CRASH REPORT',
'================================================================================',
`Process: ${data.processName} (ID: ${data.processId})`,
`Date: ${data.timestamp.toISOString()}`,
`Exit Code: ${data.exitCode ?? 'N/A'}`,
`Signal: ${data.signal ?? 'N/A'}`,
`Restart Attempt: ${data.restartCount}/10`,
];
if (data.memoryUsage !== undefined && data.memoryUsage > 0) {
lines.push(`Memory Usage: ${this.humanReadableBytes(data.memoryUsage)}`);
}
lines.push(
'================================================================================',
'',
`LAST ${data.logs.length} LOG ENTRIES:`,
'--------------------------------------------------------------------------------',
''
);
// Add log entries
for (const log of data.logs) {
const timestamp = new Date(log.timestamp).toISOString();
const type = log.type.toUpperCase().padEnd(6);
lines.push(`[${timestamp}] [${type}] ${log.message}`);
}
lines.push(
'',
'================================================================================',
'END OF CRASH REPORT',
'================================================================================',
''
);
return lines.join('\n');
}
/**
* Convert bytes to human-readable format
*/
private humanReadableBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Ensure crash logs directory exists
*/
private async ensureCrashLogsDir(): Promise<void> {
await smartfs.directory(this.crashLogsDir).create();
}
/**
* Rotate old crash logs when exceeding max count
*/
private async rotateOldLogs(): Promise<void> {
try {
// Get all crash log files
const entries = await smartfs.directory(this.crashLogsDir).list();
const files = entries.filter(e => e.name.endsWith('.log'));
if (files.length <= this.MAX_CRASH_LOGS) {
return; // No rotation needed
}
// Get file stats and sort by modification time (oldest first)
const fileStats = await Promise.all(
files.map(async (entry) => {
const filepath = plugins.path.join(this.crashLogsDir, entry.name);
const stats = await smartfs.file(filepath).stat();
return { filepath, mtime: stats.mtime.getTime() };
})
);
fileStats.sort((a, b) => a.mtime - b.mtime);
// Delete oldest files to stay under limit
const filesToDelete = fileStats.length - this.MAX_CRASH_LOGS;
for (let i = 0; i < filesToDelete; i++) {
await smartfs.file(fileStats[i].filepath).delete();
console.log(`Rotated old crash log: ${plugins.path.basename(fileStats[i].filepath)}`);
}
} catch (error) {
console.error('Failed to rotate crash logs:', error);
}
}
/**
* Get list of crash logs for a specific process
*/
public async getCrashLogsForProcess(processId: ProcessId): Promise<string[]> {
try {
await this.ensureCrashLogsDir();
const entries = await smartfs.directory(this.crashLogsDir).list();
const files = entries.filter(e => e.name.endsWith('.log') && e.name.includes(`_${processId}_`));
return files.map(entry => plugins.path.join(this.crashLogsDir, entry.name));
} catch (error) {
console.error(`Failed to get crash logs for process ${processId}:`, error);
return [];
}
}
/**
* Clean up all crash logs (for maintenance)
*/
public async cleanupAllCrashLogs(): Promise<void> {
try {
await this.ensureCrashLogsDir();
const entries = await smartfs.directory(this.crashLogsDir).list();
const files = entries.filter(e => e.name.endsWith('.log'));
for (const entry of files) {
const filepath = plugins.path.join(this.crashLogsDir, entry.name);
await smartfs.file(filepath).delete();
}
console.log(`Cleaned up ${files.length} crash logs`);
} catch (error) {
console.error('Failed to cleanup crash logs:', error);
}
}
}

View File

@@ -3,6 +3,8 @@ import * as paths from '../paths.js';
import type { IProcessLog } from '../shared/protocol/ipc.types.js';
import type { ProcessId } from '../shared/protocol/id.js';
const smartfs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
/**
* Manages persistent log storage for processes
*/
@@ -24,7 +26,7 @@ export class LogPersistence {
* Ensure the logs directory exists
*/
private async ensureLogsDir(): Promise<void> {
await plugins.smartfile.fs.ensureDir(this.logsDir);
await smartfs.directory(this.logsDir).create();
}
/**
@@ -35,10 +37,7 @@ export class LogPersistence {
const filePath = this.getLogFilePath(processId);
// Write logs as JSON
await plugins.smartfile.memory.toFs(
JSON.stringify(logs, null, 2),
filePath
);
await smartfs.file(filePath).encoding('utf8').write(JSON.stringify(logs, null, 2));
}
/**
@@ -48,12 +47,12 @@ export class LogPersistence {
const filePath = this.getLogFilePath(processId);
try {
const exists = await plugins.smartfile.fs.fileExists(filePath);
const exists = await smartfs.file(filePath).exists();
if (!exists) {
return [];
}
const content = await plugins.smartfile.fs.toStringSync(filePath);
const content = await smartfs.file(filePath).encoding('utf8').read() as string;
const logs = JSON.parse(content) as IProcessLog[];
// Convert date strings back to Date objects
@@ -74,9 +73,9 @@ export class LogPersistence {
const filePath = this.getLogFilePath(processId);
try {
const exists = await plugins.smartfile.fs.fileExists(filePath);
const exists = await smartfs.file(filePath).exists();
if (exists) {
await plugins.smartfile.fs.remove(filePath);
await smartfs.file(filePath).delete();
}
} catch (error) {
console.error(`Failed to delete logs for process ${processId}:`, error);
@@ -98,16 +97,17 @@ export class LogPersistence {
public async cleanupOldLogs(): Promise<void> {
try {
await this.ensureLogsDir();
const files = await plugins.smartfile.fs.listFileTree(this.logsDir, '*.json');
const entries = await smartfs.directory(this.logsDir).list();
const files = entries.filter(e => e.name.endsWith('.json'));
for (const file of files) {
const filePath = plugins.path.join(this.logsDir, file);
const stats = await plugins.smartfile.fs.stat(filePath);
for (const entry of files) {
const filePath = plugins.path.join(this.logsDir, entry.name);
const stats = await smartfs.file(filePath).stat();
// Delete files older than 7 days
const ageInDays = (Date.now() - stats.mtime.getTime()) / (1000 * 60 * 60 * 24);
if (ageInDays > 7) {
await plugins.smartfile.fs.remove(filePath);
await smartfs.file(filePath).delete();
}
}
} catch (error) {

View File

@@ -95,6 +95,16 @@ export class ProcessManager extends EventEmitter {
// Check if process with this id already exists
if (this.processes.has(config.id)) {
const existing = this.processes.get(config.id)!;
// If an existing monitor is present but not running, treat this as a fresh start via restart logic
if (!existing.isRunning()) {
this.logger.info(
`Existing monitor found for id '${config.id}' but not running. Restarting it...`,
);
await this.restart(config.id);
return;
}
// Already running surface a meaningful error
throw new ValidationError(
`Process with id '${config.id}' already exists`,
'ERR_DUPLICATE_PROCESS',
@@ -246,7 +256,8 @@ export class ProcessManager extends EventEmitter {
try {
await monitor.stop();
this.updateProcessInfo(id, { status: 'stopped' });
// Ensure status and PID are reflected immediately
this.updateProcessInfo(id, { status: 'stopped', pid: undefined });
this.logger.info(`Successfully stopped process with id '${id}'`);
} catch (error: Error | unknown) {
const processError = new ProcessError(
@@ -430,6 +441,8 @@ export class ProcessManager extends EventEmitter {
const pid = monitor.getPid();
if (pid) {
info.pid = pid;
} else {
info.pid = undefined;
}
// Update uptime if available
@@ -438,13 +451,18 @@ export class ProcessManager extends EventEmitter {
info.uptime = uptime;
}
// Update memory and cpu from latest monitor readings
info.memory = monitor.getLastMemoryUsage();
const cpu = monitor.getLastCpuUsage();
if (Number.isFinite(cpu)) {
info.cpu = cpu;
}
// Update restart count
info.restarts = monitor.getRestartCount();
// Update status based on actual running state
if (monitor.isRunning()) {
info.status = 'online';
}
info.status = monitor.isRunning() ? 'online' : 'stopped';
}
}
@@ -492,8 +510,12 @@ export class ProcessManager extends EventEmitter {
*/
public async startAll(): Promise<void> {
for (const [id, config] of this.processConfigs.entries()) {
if (!this.processes.has(id)) {
const monitor = this.processes.get(id);
if (!monitor) {
await this.start(config);
} else if (!monitor.isRunning()) {
// If a monitor exists but is not running, restart the process to ensure a clean start
await this.restart(id);
}
}
}
@@ -717,7 +739,8 @@ export class ProcessManager extends EventEmitter {
throw configError;
}
} else {
this.logger.info('No saved process configurations found');
// First run / no configs yet — keep this quiet unless debugging
this.logger.debug('No saved process configurations found');
}
} catch (error: Error | unknown) {
// Only throw if it's not the "no configs found" case
@@ -726,9 +749,7 @@ export class ProcessManager extends EventEmitter {
}
// If no configs found or error reading, just continue with empty configs
this.logger.info(
'No saved process configurations found or error reading them',
);
this.logger.debug('No saved process configurations found or error reading them');
}
}

View File

@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
import { EventEmitter } from 'events';
import { ProcessWrapper } from './processwrapper.js';
import { LogPersistence } from './logpersistence.js';
import { CrashLogManager } from './crashlogmanager.js';
import { Logger, ProcessError, handleError } from '../shared/common/utils.errorhandler.js';
import type { IMonitorConfig, IProcessLog } from '../shared/protocol/ipc.types.js';
import type { ProcessId } from '../shared/protocol/id.js';
@@ -15,13 +16,23 @@ export class ProcessMonitor extends EventEmitter {
private logger: Logger;
private logs: IProcessLog[] = [];
private logPersistence: LogPersistence;
private crashLogManager: CrashLogManager;
private processId?: ProcessId;
private currentLogMemorySize: number = 0;
private readonly MAX_LOG_MEMORY_SIZE = 10 * 1024 * 1024; // 10MB
// Track approximate size per log to avoid O(n) JSON stringify on every update
private logSizeMap: WeakMap<IProcessLog, number> = new WeakMap();
private restartTimer: NodeJS.Timeout | null = null;
private lastRetryAt: number | null = null;
private readonly MAX_RETRIES = 10;
private readonly RESET_WINDOW_MS = 60 * 60 * 1000; // 1 hour
private lastMemoryUsage: number = 0;
private lastCpuUsage: number = 0;
// Store event listeners for cleanup
private logHandler?: (log: IProcessLog) => void;
private startHandler?: (pid: number) => void;
private exitHandler?: (code: number | null, signal: string | null) => Promise<void>;
private errorHandler?: (error: Error | ProcessError) => Promise<void>;
constructor(config: IMonitorConfig & { id?: ProcessId }) {
super();
@@ -29,6 +40,7 @@ export class ProcessMonitor extends EventEmitter {
this.logger = new Logger(`ProcessMonitor:${config.name || 'unnamed'}`);
this.logs = [];
this.logPersistence = new LogPersistence();
this.crashLogManager = new CrashLogManager();
this.processId = config.id;
this.currentLogMemorySize = 0;
}
@@ -39,7 +51,13 @@ export class ProcessMonitor extends EventEmitter {
const persistedLogs = await this.logPersistence.loadLogs(this.processId);
if (persistedLogs.length > 0) {
this.logs = persistedLogs;
this.currentLogMemorySize = LogPersistence.calculateLogMemorySize(this.logs);
// Recalculate size once from scratch and seed the size map
this.currentLogMemorySize = 0;
for (const log of this.logs) {
const size = this.estimateLogSize(log);
this.logSizeMap.set(log, size);
this.currentLogMemorySize += size;
}
this.logger.info(`Loaded ${persistedLogs.length} persisted logs from disk`);
// Delete the persisted file after loading
@@ -73,6 +91,14 @@ export class ProcessMonitor extends EventEmitter {
this.logger.info(`Spawning process: ${this.config.command}`);
// Clear any orphaned pidusage cache entries before spawning
try {
(plugins.pidusage as any)?.clearAll?.();
} catch {}
// Clean up previous listeners if any
this.cleanupListeners();
// Create a new process wrapper
this.processWrapper = new ProcessWrapper({
name: this.config.name || 'unnamed-process',
@@ -84,21 +110,31 @@ export class ProcessMonitor extends EventEmitter {
});
// Set up event handlers
this.processWrapper.on('log', (log: IProcessLog): void => {
this.logHandler = (log: IProcessLog): void => {
// Store the log in our buffer
this.logs.push(log);
console.error(`[ProcessMonitor:${this.config.name}] Received log (type=${log.type}): ${log.message}`);
console.error(`[ProcessMonitor:${this.config.name}] Logs array now has ${this.logs.length} items`);
if (process.env.TSPM_DEBUG) {
console.error(
`[ProcessMonitor:${this.config.name}] Received log (type=${log.type}): ${log.message}`,
);
console.error(
`[ProcessMonitor:${this.config.name}] Logs array now has ${this.logs.length} items`,
);
}
this.logger.debug(`ProcessMonitor received log: ${log.message}`);
// Update memory size tracking
this.currentLogMemorySize = LogPersistence.calculateLogMemorySize(this.logs);
// Update memory size tracking incrementally
const approxSize = this.estimateLogSize(log);
this.logSizeMap.set(log, approxSize);
this.currentLogMemorySize += approxSize;
// Trim logs if they exceed memory limit (10MB)
while (this.currentLogMemorySize > this.MAX_LOG_MEMORY_SIZE && this.logs.length > 1) {
// Remove oldest logs until we're under the memory limit
this.logs.shift();
this.currentLogMemorySize = LogPersistence.calculateLogMemorySize(this.logs);
const removed = this.logs.shift()!;
const removedSize = this.logSizeMap.get(removed) ?? this.estimateLogSize(removed);
this.logSizeMap.delete(removed); // Clean up map entry to prevent memory leak
this.currentLogMemorySize -= removedSize;
}
// Re-emit the log event for upstream handlers
@@ -108,20 +144,49 @@ export class ProcessMonitor extends EventEmitter {
if (log.type === 'system') {
this.log(log.message);
}
});
};
this.processWrapper.on('log', this.logHandler);
// Re-emit start event with PID for upstream handlers
this.processWrapper.on('start', (pid: number): void => {
this.startHandler = (pid: number): void => {
this.emit('start', pid);
});
};
this.processWrapper.on('start', this.startHandler);
this.processWrapper.on(
'exit',
async (code: number | null, signal: string | null): Promise<void> => {
this.exitHandler = async (code: number | null, signal: string | null): Promise<void> => {
const exitMsg = `Process exited with code ${code}, signal ${signal}.`;
this.logger.info(exitMsg);
this.log(exitMsg);
// Clear pidusage internal state for this PID to prevent memory leaks
try {
const pidToClear = this.processWrapper?.getPid();
if (pidToClear) {
(plugins.pidusage as any)?.clear?.(pidToClear);
}
} catch {}
// Detect if this was a crash (non-zero exit code or killed by signal)
const isCrash = (code !== null && code !== 0) || signal !== null;
// Save crash log if this was a crash
if (isCrash && this.processId && this.config.name) {
try {
await this.crashLogManager.saveCrashLog(
this.processId,
this.config.name,
this.logs,
code,
signal,
this.restartCount,
this.lastMemoryUsage
);
this.logger.info(`Saved crash log for process ${this.config.name}`);
} catch (error) {
this.logger.error(`Failed to save crash log: ${error}`);
}
}
// Flush logs to disk on exit
if (this.processId && this.logs.length > 0) {
try {
@@ -142,10 +207,10 @@ export class ProcessMonitor extends EventEmitter {
'Not restarting process because monitor is stopped',
);
}
},
);
};
this.processWrapper.on('exit', this.exitHandler);
this.processWrapper.on('error', async (error: Error | ProcessError): Promise<void> => {
this.errorHandler = async (error: Error | ProcessError): Promise<void> => {
const errorMsg =
error instanceof ProcessError
? `Process error: ${error.toString()}`
@@ -154,6 +219,24 @@ export class ProcessMonitor extends EventEmitter {
this.logger.error(error);
this.log(errorMsg);
// Save crash log for errors
if (this.processId && this.config.name) {
try {
await this.crashLogManager.saveCrashLog(
this.processId,
this.config.name,
this.logs,
null, // no exit code for errors
null, // no signal for errors
this.restartCount,
this.lastMemoryUsage
);
this.logger.info(`Saved crash log for process ${this.config.name} due to error`);
} catch (crashLogError) {
this.logger.error(`Failed to save crash log: ${crashLogError}`);
}
}
// Flush logs to disk on error
if (this.processId && this.logs.length > 0) {
try {
@@ -169,7 +252,8 @@ export class ProcessMonitor extends EventEmitter {
} else {
this.logger.debug('Not restarting process because monitor is stopped');
}
});
};
this.processWrapper.on('error', this.errorHandler);
// Start the process
try {
@@ -183,6 +267,31 @@ export class ProcessMonitor extends EventEmitter {
}
}
/**
* Clean up event listeners from process wrapper
*/
private cleanupListeners(): void {
if (this.processWrapper) {
if (this.logHandler) {
this.processWrapper.removeListener('log', this.logHandler);
}
if (this.startHandler) {
this.processWrapper.removeListener('start', this.startHandler);
}
if (this.exitHandler) {
this.processWrapper.removeListener('exit', this.exitHandler);
}
if (this.errorHandler) {
this.processWrapper.removeListener('error', this.errorHandler);
}
}
// Clear references
this.logHandler = undefined;
this.startHandler = undefined;
this.exitHandler = undefined;
this.errorHandler = undefined;
}
/**
* Schedule a restart with incremental debounce and failure cutoff.
*/
@@ -235,18 +344,24 @@ export class ProcessMonitor extends EventEmitter {
memoryLimit: number,
): Promise<void> {
try {
const memoryUsage = await this.getProcessGroupMemory(pid);
const { memory: memoryUsage, cpu: cpuUsage } = await this.getProcessGroupStats(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
// Store latest readings
this.lastMemoryUsage = memoryUsage;
this.lastCpuUsage = cpuUsage;
// Only log memory usage in debug mode to avoid spamming
if (process.env.TSPM_DEBUG) {
this.log(
`Current memory usage for process group (PID ${pid}): ${this.humanReadableBytes(
memoryUsage,
)} (${memoryUsage} bytes)`,
);
}
if (memoryUsage > memoryLimit) {
const memoryLimitMsg = `Memory usage ${this.humanReadableBytes(
@@ -258,7 +373,7 @@ export class ProcessMonitor extends EventEmitter {
// Stop the process wrapper, which will trigger the exit handler and restart
if (this.processWrapper) {
this.processWrapper.stop();
await this.processWrapper.stop();
}
}
} catch (error: Error | unknown) {
@@ -276,7 +391,7 @@ export class ProcessMonitor extends EventEmitter {
/**
* Get the total memory usage (in bytes) for the process group (the main process and its children).
*/
private getProcessGroupMemory(pid: number): Promise<number> {
private getProcessGroupStats(pid: number): Promise<{ memory: number; cpu: number }> {
return new Promise((resolve, reject) => {
this.logger.debug(
`Getting memory usage for process group with PID ${pid}`,
@@ -284,7 +399,7 @@ export class ProcessMonitor extends EventEmitter {
plugins.psTree(
pid,
(err: Error | null, children: Array<{ PID: string }>) => {
(err: any, children: ReadonlyArray<{ PID: string }>) => {
if (err) {
const processError = new ProcessError(
`Failed to get process tree: ${err.message}`,
@@ -306,7 +421,7 @@ export class ProcessMonitor extends EventEmitter {
plugins.pidusage(
pids,
(err: Error | null, stats: Record<string, { memory: number }>) => {
(err: Error | null, stats: Record<string, { memory: number; cpu: number }>) => {
if (err) {
const processError = new ProcessError(
`Failed to get process usage stats: ${err.message}`,
@@ -318,14 +433,22 @@ export class ProcessMonitor extends EventEmitter {
}
let totalMemory = 0;
let totalCpu = 0;
for (const key in stats) {
totalMemory += stats[key].memory;
// Check if stats[key] exists and is not null (process may have exited)
if (stats[key]) {
totalMemory += stats[key].memory || 0;
totalCpu += Number.isFinite(stats[key].cpu) ? stats[key].cpu : 0;
} else {
this.logger.debug(`Process ${key} stats are null (process may have exited)`);
}
}
this.logger.debug(
`Total memory for process group: ${this.humanReadableBytes(totalMemory)}`,
);
resolve(totalMemory);
resolve({ memory: totalMemory, cpu: totalCpu });
},
);
},
@@ -352,6 +475,9 @@ export class ProcessMonitor extends EventEmitter {
this.log('Stopping process monitor.');
this.stopped = true;
// Clean up event listeners
this.cleanupListeners();
// Flush logs to disk before stopping
if (this.processId && this.logs.length > 0) {
try {
@@ -365,8 +491,20 @@ export class ProcessMonitor extends EventEmitter {
if (this.intervalId) {
clearInterval(this.intervalId);
}
// Cancel any pending restart timer
if (this.restartTimer) {
clearTimeout(this.restartTimer);
this.restartTimer = null;
}
if (this.processWrapper) {
this.processWrapper.stop();
// Clear pidusage state for current PID before stopping to avoid leaks
try {
const pidToClear = this.processWrapper.getPid();
if (pidToClear) {
(plugins.pidusage as any)?.clear?.(pidToClear);
}
} catch {}
await this.processWrapper.stop();
}
}
@@ -374,7 +512,11 @@ export class ProcessMonitor extends EventEmitter {
* Get the current logs from the process
*/
public getLogs(limit?: number): IProcessLog[] {
console.error(`[ProcessMonitor:${this.config.name}] getLogs called, logs.length=${this.logs.length}, limit=${limit}`);
if (process.env.TSPM_DEBUG) {
console.error(
`[ProcessMonitor:${this.config.name}] getLogs called, logs.length=${this.logs.length}, limit=${limit}`,
);
}
this.logger.debug(`Getting logs, total stored: ${this.logs.length}`);
if (limit && limit > 0) {
return this.logs.slice(-limit);
@@ -410,6 +552,20 @@ export class ProcessMonitor extends EventEmitter {
return this.processWrapper?.isRunning() || false;
}
/**
* Get last measured memory usage for the process group (bytes)
*/
public getLastMemoryUsage(): number {
return this.lastMemoryUsage;
}
/**
* Get last measured CPU usage for the process group (sum of group, percent)
*/
public getLastCpuUsage(): number {
return this.lastCpuUsage;
}
/**
* Helper method for logging messages with the instance name.
*/
@@ -417,4 +573,17 @@ export class ProcessMonitor extends EventEmitter {
const prefix = this.config.name ? `[${this.config.name}] ` : '';
console.log(prefix + message);
}
/**
* Estimate approximate memory size in bytes for a log entry.
* Keeps CPU low by avoiding JSON.stringify on the full array.
*/
private estimateLogSize(log: IProcessLog): number {
const messageBytes = Buffer.byteLength(log.message || '', 'utf8');
const typeBytes = Buffer.byteLength(log.type || '', 'utf8');
const runIdBytes = Buffer.byteLength((log as any).runId || '', 'utf8');
// Rough overhead for object structure, keys, timestamp/seq values
const overhead = 64;
return messageBytes + typeBytes + runIdBytes + overhead;
}
}

View File

@@ -23,6 +23,33 @@ export class ProcessWrapper extends EventEmitter {
private runId: string = '';
private stdoutRemainder: string = '';
private stderrRemainder: string = '';
// Store event handlers for cleanup
private exitHandler?: (code: number | null, signal: string | null) => void;
private errorHandler?: (error: Error) => void;
private stdoutDataHandler?: (data: Buffer) => void;
private stdoutEndHandler?: () => void;
private stderrDataHandler?: (data: Buffer) => void;
private stderrEndHandler?: () => void;
// Helper: send a signal to the process and all its children (best-effort)
private async killProcessTree(signal: NodeJS.Signals): Promise<void> {
if (!this.process || !this.process.pid) return;
const rootPid = this.process.pid;
await new Promise<void>((resolve) => {
plugins.psTree(rootPid, (err: any, children: ReadonlyArray<{ PID: string }>) => {
const pids: number[] = [rootPid, ...children.map((c) => Number(c.PID)).filter((n) => Number.isFinite(n))];
for (const pid of pids) {
try {
// Always signal individual PIDs to avoid accidentally targeting unrelated groups
process.kill(pid, signal);
} catch {
// ignore ESRCH/EPERM
}
}
resolve();
});
});
}
constructor(options: IProcessWrapperOptions) {
super();
@@ -64,7 +91,7 @@ export class ProcessWrapper extends EventEmitter {
this.startTime = new Date();
// Handle process exit
this.process.on('exit', (code, signal) => {
this.exitHandler = (code, signal) => {
const exitMessage = `Process exited with code ${code}, signal ${signal}`;
this.logger.info(exitMessage);
this.addSystemLog(exitMessage);
@@ -73,11 +100,15 @@ export class ProcessWrapper extends EventEmitter {
this.stdoutRemainder = '';
this.stderrRemainder = '';
// Mark process reference as gone so isRunning() reflects reality
this.process = null;
this.emit('exit', code, signal);
});
};
this.process.on('exit', this.exitHandler);
// Handle errors
this.process.on('error', (error) => {
this.errorHandler = (error) => {
const processError = new ProcessError(
error.message,
'ERR_PROCESS_EXECUTION',
@@ -86,13 +117,24 @@ export class ProcessWrapper extends EventEmitter {
this.logger.error(processError);
this.addSystemLog(`Process error: ${processError.toString()}`);
this.emit('error', processError);
});
};
this.process.on('error', this.errorHandler);
// Capture stdout
if (this.process.stdout) {
console.error(`[ProcessWrapper] Setting up stdout listener for process ${this.process.pid}`);
this.process.stdout.on('data', (data) => {
console.error(`[ProcessWrapper] Received stdout data from PID ${this.process?.pid}: ${data.toString().substring(0, 100)}`);
if (process.env.TSPM_DEBUG) {
console.error(
`[ProcessWrapper] Setting up stdout listener for process ${this.process.pid}`,
);
}
this.stdoutDataHandler = (data) => {
if (process.env.TSPM_DEBUG) {
console.error(
`[ProcessWrapper] Received stdout data from PID ${this.process?.pid}: ${data
.toString()
.substring(0, 100)}`,
);
}
// Add data to remainder buffer and split by newlines
const text = this.stdoutRemainder + data.toString();
const lines = text.split('\n');
@@ -102,27 +144,31 @@ export class ProcessWrapper extends EventEmitter {
// Process complete lines
for (const line of lines) {
if (process.env.TSPM_DEBUG) {
console.error(`[ProcessWrapper] Processing stdout line: ${line}`);
}
this.logger.debug(`Captured stdout: ${line}`);
this.addLog('stdout', line);
}
});
};
this.process.stdout.on('data', this.stdoutDataHandler);
// Flush remainder on stream end
this.process.stdout.on('end', () => {
this.stdoutEndHandler = () => {
if (this.stdoutRemainder) {
this.logger.debug(`Flushing stdout remainder: ${this.stdoutRemainder}`);
this.addLog('stdout', this.stdoutRemainder);
this.stdoutRemainder = '';
}
});
};
this.process.stdout.on('end', this.stdoutEndHandler);
} else {
this.logger.warn('Process stdout is null');
}
// Capture stderr
if (this.process.stderr) {
this.process.stderr.on('data', (data) => {
this.stderrDataHandler = (data) => {
// Add data to remainder buffer and split by newlines
const text = this.stderrRemainder + data.toString();
const lines = text.split('\n');
@@ -134,15 +180,17 @@ export class ProcessWrapper extends EventEmitter {
for (const line of lines) {
this.addLog('stderr', line);
}
});
};
this.process.stderr.on('data', this.stderrDataHandler);
// Flush remainder on stream end
this.process.stderr.on('end', () => {
this.stderrEndHandler = () => {
if (this.stderrRemainder) {
this.addLog('stderr', this.stderrRemainder);
this.stderrRemainder = '';
}
});
};
this.process.stderr.on('end', this.stderrEndHandler);
}
this.addSystemLog(`Process started with PID ${this.process.pid}`);
@@ -165,46 +213,105 @@ export class ProcessWrapper extends EventEmitter {
}
}
/**
* Clean up event listeners from process and streams
*/
private cleanupListeners(): void {
if (this.process) {
if (this.exitHandler) {
this.process.removeListener('exit', this.exitHandler);
}
if (this.errorHandler) {
this.process.removeListener('error', this.errorHandler);
}
if (this.process.stdout) {
if (this.stdoutDataHandler) {
this.process.stdout.removeListener('data', this.stdoutDataHandler);
}
if (this.stdoutEndHandler) {
this.process.stdout.removeListener('end', this.stdoutEndHandler);
}
}
if (this.process.stderr) {
if (this.stderrDataHandler) {
this.process.stderr.removeListener('data', this.stderrDataHandler);
}
if (this.stderrEndHandler) {
this.process.stderr.removeListener('end', this.stderrEndHandler);
}
}
}
// Clear references
this.exitHandler = undefined;
this.errorHandler = undefined;
this.stdoutDataHandler = undefined;
this.stdoutEndHandler = undefined;
this.stderrDataHandler = undefined;
this.stderrEndHandler = undefined;
}
/**
* Stop the wrapped process
*/
public stop(): void {
public async stop(): Promise<void> {
if (!this.process) {
this.logger.debug('Stop called but no process is running');
this.addSystemLog('No process running');
return;
}
// Clean up event listeners before stopping
this.cleanupListeners();
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');
this.logger.debug(`Sending SIGTERM to process tree rooted at ${this.process.pid}`);
await this.killProcessTree('SIGTERM');
// Give it 5 seconds to shut down gracefully
setTimeout((): void => {
if (this.process && this.process.pid) {
// If the process already exited, return immediately
if (typeof this.process.exitCode === 'number') {
this.logger.debug('Process already exited, no need to wait');
return;
}
// Wait for exit or escalate
await new Promise<void>((resolve) => {
let settled = false;
const cleanup = () => {
if (settled) return;
settled = true;
resolve();
};
const onExit = () => cleanup();
this.process!.once('exit', onExit);
const killTimer = setTimeout(async () => {
if (!this.process || !this.process.pid) return cleanup();
this.logger.warn(
`Process ${this.process.pid} did not exit gracefully, force killing...`,
);
this.addSystemLog(
'Process did not exit gracefully, force killing...',
`Process ${this.process.pid} did not exit gracefully, force killing tree...`,
);
this.addSystemLog('Process did not exit gracefully, force killing...');
try {
process.kill(this.process.pid, 'SIGKILL');
} 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)
}`,
);
}
}
await this.killProcessTree('SIGKILL');
} catch {}
// Give a short grace period after SIGKILL
setTimeout(() => cleanup(), 500);
}, 5000);
// Safety cap in case neither exit nor timer fires (shouldn't happen)
setTimeout(() => {
clearTimeout(killTimer);
cleanup();
}, 10000);
});
} catch (error: Error | unknown) {
const processError = new ProcessError(
error instanceof Error ? error.message : String(error),
@@ -221,6 +328,7 @@ export class ProcessWrapper extends EventEmitter {
* Get the process ID if running
*/
public getPid(): number | null {
if (!this.isRunning()) return null;
return this.process?.pid || null;
}
@@ -244,7 +352,13 @@ export class ProcessWrapper extends EventEmitter {
* Check if the process is currently running
*/
public isRunning(): boolean {
return this.process !== null && typeof this.process.exitCode !== 'number';
if (!this.process) return false;
// In Node, while the child is running: exitCode === null and signalCode === null/undefined
// After it exits: exitCode is a number OR signalCode is a string
const anyProc: any = this.process as any;
const exitCode = anyProc.exitCode;
const signalCode = anyProc.signalCode;
return exitCode === null && (signalCode === null || typeof signalCode === 'undefined');
}
/**

View File

@@ -1,20 +1,20 @@
import * as plugins from '../plugins.js';
export class TspmConfig {
public npmextraInstance = new plugins.npmextra.KeyValueStore({
public smartconfigInstance = new plugins.smartconfig.KeyValueStore({
identityArg: '@git.zone__tspm',
typeArg: 'userHomeDir',
});
public async readKey(keyArg: string): Promise<string> {
return await this.npmextraInstance.readKey(keyArg);
return await this.smartconfigInstance.readKey(keyArg);
}
public async writeKey(keyArg: string, value: string): Promise<void> {
return await this.npmextraInstance.writeKey(keyArg, value);
return await this.smartconfigInstance.writeKey(keyArg, value);
}
public async deleteKey(keyArg: string): Promise<void> {
return await this.npmextraInstance.deleteKey(keyArg);
return await this.smartconfigInstance.deleteKey(keyArg);
}
}

View File

@@ -10,6 +10,7 @@ import type {
DaemonStatusResponse,
HeartbeatResponse,
} from '../shared/protocol/ipc.types.js';
import { LogPersistence } from './logpersistence.js';
/**
* Central daemon server that manages all TSPM processes
@@ -97,9 +98,25 @@ export class TspmDaemon {
this.tspmInstance.on('process:log', ({ processId, log }) => {
// Publish to topic for this process
const topic = `logs.${processId}`;
// Broadcast to all connected clients subscribed to this topic
// Deliver only to subscribed clients
if (this.ipcServer) {
this.ipcServer.broadcast(`topic:${topic}`, log);
try {
const topicIndex = (this.ipcServer as any).topicIndex as Map<string, Set<string>> | undefined;
const subscribers = topicIndex?.get(topic);
if (subscribers && subscribers.size > 0) {
// Send directly to subscribers for this topic
for (const clientId of subscribers) {
this.ipcServer
.sendToClient(clientId, `topic:${topic}`, log)
.catch((err: any) => {
// Surface but don't fail the loop
console.error('[IPC] sendToClient error:', err?.message || err);
});
}
}
} catch (err: any) {
console.error('[IPC] Topic delivery error:', err?.message || err);
}
}
});
@@ -154,7 +171,22 @@ export class TspmDaemon {
throw new Error(`Process ${id} not found`);
}
await this.tspmInstance.setDesiredState(id, 'online');
const existing = this.tspmInstance.processes.get(id);
if (existing) {
if (existing.isRunning()) {
// Already running; return current status/pid
const runningInfo = this.tspmInstance.processInfo.get(id);
return {
processId: id,
pid: runningInfo?.pid,
status: runningInfo?.status || 'online',
};
} else {
await this.tspmInstance.restart(id);
}
} else {
await this.tspmInstance.start(config);
}
const processInfo = this.tspmInstance.processInfo.get(id);
return {
processId: id,
@@ -277,11 +309,80 @@ export class TspmDaemon {
this.ipcServer.onMessage(
'getLogs',
async (request: RequestForMethod<'getLogs'>) => {
const logs = await this.tspmInstance.getLogs(toProcessId(request.id));
const id = toProcessId(request.id);
const logs = await this.tspmInstance.getLogs(id, request.lines);
return { logs };
},
);
// Stream backlog logs and let client subscribe to live topic separately
this.ipcServer.onMessage(
'logs:subscribe',
async (
request: RequestForMethod<'logs:subscribe'>,
clientId: string,
) => {
const id = toProcessId(request.id);
// Determine backlog set
const allLogs = await this.tspmInstance.getLogs(id);
let filtered = allLogs;
if (request.types && request.types.length) {
filtered = filtered.filter((l) => request.types!.includes(l.type));
}
if (request.sinceTime && request.sinceTime > 0) {
filtered = filtered.filter(
(l) => new Date(l.timestamp).getTime() >= request.sinceTime!,
);
}
const lines = request.lines && request.lines > 0 ? request.lines : 0;
if (lines > 0 && filtered.length > lines) {
filtered = filtered.slice(-lines);
}
// Send backlog entries directly to the requesting client as topic messages
// in small batches to avoid overwhelming the transport or client.
const chunkSize = 200;
for (let i = 0; i < filtered.length; i += chunkSize) {
const chunk = filtered.slice(i, i + chunkSize);
await Promise.allSettled(
chunk.map((entry) =>
this.ipcServer.sendToClient(
clientId,
`topic:logs.backlog.${id}`,
{
...entry,
timestamp: new Date(entry.timestamp).getTime(),
},
),
),
);
// Yield a bit between chunks
await new Promise((r) => setTimeout(r, 5));
}
return { ok: true } as any;
},
);
// Inspect subscribers for a process log topic
this.ipcServer.onMessage(
'logs:subscribers',
async (
request: RequestForMethod<'logs:subscribers'>,
clientId: string,
) => {
const id = toProcessId(request.id);
const topic = `logs.${id}`;
try {
const topicIndex = (this.ipcServer as any).topicIndex as Map<string, Set<string>> | undefined;
const subs = Array.from(topicIndex?.get(topic) || []);
// Also include the requesting clientId if it has a local handler without subscription
return { topic, subscribers: subs, count: subs.length } as any;
} catch (err: any) {
return { topic, subscribers: [], count: 0 } as any;
}
},
);
// Resolve target (id:n | name:foo | numeric string) to ProcessId
this.ipcServer.onMessage(
'resolveTarget',
@@ -365,10 +466,12 @@ export class TspmDaemon {
await this.tspmInstance.setDesiredStateForAll('stopped');
await this.tspmInstance.stopAll();
// Yield briefly to allow any pending exit events to settle
await new Promise((r) => setTimeout(r, 50));
// Get status of all processes
for (const [id, processInfo] of this.tspmInstance.processInfo) {
if (processInfo.status === 'stopped') {
// Determine which monitors are no longer running
for (const [id, monitor] of this.tspmInstance.processes) {
if (!monitor.isRunning()) {
stopped.push(id);
} else {
failed.push({ id, error: 'Failed to stop' });
@@ -414,6 +517,28 @@ export class TspmDaemon {
'daemon:status',
async (request: RequestForMethod<'daemon:status'>) => {
const memUsage = process.memoryUsage();
// Aggregate log stats from monitors
let totalLogCount = 0;
let totalLogBytes = 0;
const perProcess: Array<{ id: ProcessId; count: number; bytes: number }> = [];
for (const [id, monitor] of this.tspmInstance.processes.entries()) {
try {
const logs = monitor.getLogs();
const count = logs.length;
const bytes = LogPersistence.calculateLogMemorySize(logs);
totalLogCount += count;
totalLogBytes += bytes;
perProcess.push({ id, count, bytes });
} catch {}
}
const pathsInfo = {
tspmDir: paths.tspmDir,
socketPath: this.socketPath,
pidFile: this.daemonPidFile,
};
const configsInfo = {
processConfigs: this.tspmInstance.processConfigs.size,
};
return {
status: 'running',
pid: process.pid,
@@ -422,6 +547,13 @@ export class TspmDaemon {
memoryUsage: memUsage.heapUsed,
cpuUsage: process.cpuUsage().user / 1000000, // Convert to seconds
version: this.version,
logsInMemory: {
totalCount: totalLogCount,
totalBytes: totalLogBytes,
perProcess,
},
paths: pathsInfo,
configs: configsInfo,
};
},
);

View File

@@ -6,17 +6,17 @@ import * as path from 'node:path';
export { childProcess, path };
// @push.rocks scope
import * as npmextra from '@push.rocks/npmextra';
import * as smartconfig from '@push.rocks/smartconfig';
import * as projectinfo from '@push.rocks/projectinfo';
import * as smartcli from '@push.rocks/smartcli';
import * as smartdaemon from '@push.rocks/smartdaemon';
import * as smartfile from '@push.rocks/smartfile';
import * as smartfs from '@push.rocks/smartfs';
import * as smartipc from '@push.rocks/smartipc';
import * as smartpath from '@push.rocks/smartpath';
import * as smartinteract from '@push.rocks/smartinteract';
// Export with explicit module types
export { npmextra, projectinfo, smartcli, smartdaemon, smartfile, smartipc, smartpath, smartinteract };
export { smartconfig, projectinfo, smartcli, smartdaemon, smartfs, smartipc, smartpath, smartinteract };
// third-party scope
import psTree from 'ps-tree';

View File

@@ -139,6 +139,29 @@ export interface GetLogsResponse {
logs: IProcessLog[];
}
// Subscribe and stream backlog logs
export interface LogsSubscribeRequest {
id: ProcessId;
lines?: number; // number of backlog lines
sinceTime?: number; // ms epoch
types?: Array<IProcessLog['type']>;
}
export interface LogsSubscribeResponse {
ok: boolean;
}
// Inspect current subscribers for a process log topic
export interface LogsSubscribersRequest {
id: ProcessId;
}
export interface LogsSubscribersResponse {
topic: string;
subscribers: string[];
count: number;
}
// Start all command
export interface StartAllRequest {
// No parameters needed
@@ -205,6 +228,20 @@ export interface DaemonStatusResponse {
memoryUsage?: number;
cpuUsage?: number;
version?: string;
// Additional metadata (optional)
paths?: {
tspmDir?: string;
socketPath?: string;
pidFile?: string;
};
configs?: {
processConfigs?: number;
};
logsInMemory?: {
totalCount: number;
totalBytes: number;
perProcess: Array<{ id: ProcessId; count: number; bytes: number }>;
};
}
// Daemon shutdown command
@@ -274,6 +311,8 @@ export type IpcMethodMap = {
list: { request: ListRequest; response: ListResponse };
describe: { request: DescribeRequest; response: DescribeResponse };
getLogs: { request: GetLogsRequest; response: GetLogsResponse };
'logs:subscribe': { request: LogsSubscribeRequest; response: LogsSubscribeResponse };
'logs:subscribers': { request: LogsSubscribersRequest; response: LogsSubscribersResponse };
startAll: { request: StartAllRequest; response: StartAllResponse };
stopAll: { request: StopAllRequest; response: StopAllResponse };
restartAll: { request: RestartAllRequest; response: RestartAllResponse };