Compare commits

..

26 Commits

Author SHA1 Message Date
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
3b21a338fb 5.3.1
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:16:31 +00:00
28680309ad fix(client(tspmIpcClient)): Use bare topic names for IPC client subscribe/unsubscribe to fix log subscription issues 2025-08-30 21:16:31 +00:00
833573eb10 5.3.0
Some checks failed
Default (tags) / security (push) Successful in 54s
Default (tags) / test (push) Failing after 4m23s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-30 16:55:10 +00:00
ebc20a9232 feat(cli/daemon/processmonitor): Add flexible target resolution and search command; improve restart/backoff and error handling 2025-08-30 16:55:10 +00:00
22a43204d4 5.2.0
Some checks failed
Default (tags) / security (push) Successful in 56s
Default (tags) / test (push) Failing after 4m25s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-30 15:11:38 +00:00
699d07ea36 feat(cli): Preserve CLI environment when adding processes, simplify edit flow, and refresh README docs 2025-08-30 15:11:38 +00:00
2b57251f47 5.1.0
Some checks failed
Default (tags) / security (push) Successful in 58s
Default (tags) / test (push) Failing after 4m27s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-30 14:02:23 +00:00
311a536fae feat(cli): Add interactive edit command and update support for process configurations 2025-08-30 14:02:22 +00:00
5036f01516 5.0.0
Some checks failed
Default (tags) / security (push) Successful in 51s
Default (tags) / test (push) Failing after 11m11s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-08-30 13:47:14 +00:00
538f282b62 BREAKING CHANGE(daemon): Introduce persistent log storage, numeric ProcessId type, and improved process monitoring / IPC handling 2025-08-30 13:47:14 +00:00
30 changed files with 2035 additions and 465 deletions

View File

@@ -1,5 +1,122 @@
# Changelog # Changelog
## 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
- Updated ts/client/tspm.ipcclient.ts to call ipcClient.subscribe/unsubscribe with the bare topic (e.g. 'logs.<id>') instead of prefixed 'topic:<...>'.
- Added comments clarifying that the IpcClient registers the 'topic:' prefix internally.
- Fixes incorrect topic registration that could prevent log streaming handlers from receiving messages.
## 2025-08-30 - 5.3.0 - feat(cli/daemon/processmonitor)
Add flexible target resolution and search command; improve restart/backoff and error handling
- Add new cli command `search` to find processes by id or name fragment.
- Allow flexible process targets in CLI commands (accepts numeric id, id:<n>, or name:<label>) for start/stop/restart/delete/describe/logs/edit commands.
- Introduce a new daemon IPC method `resolveTarget` to normalize user-provided targets to ProcessId (supports id:<n>, name:<label>, or bare numeric id).
- Keep `remove` as a CLI alias but daemon exposes `delete` only; CLI resolves targets and always calls daemon `delete`.
- Implement scheduled restart/backoff in ProcessMonitor with incremental debounce, max retries, and a 1-hour reset window.
- Emit a `failed` event from ProcessMonitor when max restart attempts are exceeded; ProcessManager listens and marks processes as `errored` and clears pid.
- Ensure desired state is set to `stopped` before deleting a process to avoid race conditions.
- Improve cli output messages to include resolved names alongside numeric ids where available.
## 2025-08-30 - 5.2.0 - feat(cli)
Preserve CLI environment when adding processes, simplify edit flow, and refresh README docs
- CLI: When adding a process, capture and persist essential environment variables from the CLI (PATH, HOME, USER, SHELL, LANG, LC_ALL, NODE_ENV, NODE_PATH, npm_config_prefix and any TSPM_* variables). Undefined values are removed before storing.
- CLI: Interactive edit flow temporarily disabled. The edit command now displays the current configuration and updates stored environment variables to match the current CLI environment.
- Docs: Major README refresh — reorganized sections, clarified add vs start semantics, expanded examples, added daemon/service usage and programmatic API examples, and improved command reference and output examples.
## 2025-08-30 - 5.1.0 - feat(cli)
Add interactive edit command and update support for process configurations
- Add 'tspm edit' interactive CLI command to modify saved process configurations (prompts for name, command, args, cwd, memory, autorestart, watch, watch paths) with an option to replace stored PATH.
- Implement ProcessManager.update(id, updates) to merge updates, persist them, and return the updated configuration.
- Add 'update' IPC method and daemon handler to allow remote/configurations updates via IPC.
- Persist the current CLI PATH when adding a process so managed processes inherit the same PATH environment.
- Merge provided env with the runtime process.env when spawning processes to avoid fully overriding the runtime environment.
## 2025-08-30 - 5.0.0 - BREAKING CHANGE(daemon)
Introduce persistent log storage, numeric ProcessId type, and improved process monitoring / IPC handling
- Add LogPersistence: persistent on-disk storage for process logs (save/load/delete/cleanup).
- Persist logs on process exit/error/stop and trim in-memory buffers to avoid excessive memory usage.
- Introduce a branded numeric ProcessId type and toProcessId helpers; migrate IPC types and internal maps from string ids to ProcessId.
- ProcessManager refactor: typed maps for processes/configs/info/logs, async start/stop/restart flows, improved PID/uptime/restart tracking, and desired state persistence handling.
- ProcessMonitor refactor: async lifecycle (start/stop), load persisted logs on startup, flush logs to disk on exit/error/stop, log memory capping, and improved event emissions.
- ProcessWrapper improvements: buffer stdout/stderr remainders, flush partial lines on stream end, clearer debug logging.
- IPC client/server changes: handlers now normalize ids with toProcessId, subscribe/unsubscribe accept numeric/string ids, getLogs/start/stop/restart/delete use typed ids.
- CLI tweaks: format process id output safely with String() to avoid formatting issues.
- Add dependency and plugin export for @push.rocks/smartfile and update package.json accordingly.
## 2025-08-29 - 4.4.2 - fix(daemon) ## 2025-08-29 - 4.4.2 - fix(daemon)
Fix daemon IPC id handling, reload configs on demand and correct CLI daemon start path Fix daemon IPC id handling, reload configs on demand and correct CLI daemon start path

View File

@@ -1,6 +1,6 @@
{ {
"name": "@git.zone/tspm", "name": "@git.zone/tspm",
"version": "4.4.2", "version": "5.6.2",
"private": false, "private": false,
"description": "a no fuzz process manager", "description": "a no fuzz process manager",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@@ -24,7 +24,7 @@
"tspm": "./cli.js" "tspm": "./cli.js"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.6.7", "@git.zone/tsbuild": "^2.6.8",
"@git.zone/tsbundle": "^2.5.1", "@git.zone/tsbundle": "^2.5.1",
"@git.zone/tsrun": "^1.2.46", "@git.zone/tsrun": "^1.2.46",
"@git.zone/tstest": "^2.3.5", "@git.zone/tstest": "^2.3.5",
@@ -36,9 +36,12 @@
"@push.rocks/projectinfo": "^5.0.2", "@push.rocks/projectinfo": "^5.0.2",
"@push.rocks/smartcli": "^4.0.11", "@push.rocks/smartcli": "^4.0.11",
"@push.rocks/smartdaemon": "^2.0.9", "@push.rocks/smartdaemon": "^2.0.9",
"@push.rocks/smartfile": "^11.2.7",
"@push.rocks/smartinteract": "^2.0.16", "@push.rocks/smartinteract": "^2.0.16",
"@push.rocks/smartipc": "^2.2.2", "@push.rocks/smartipc": "^2.3.0",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@types/pidusage": "^2.0.5",
"@types/ps-tree": "^1.1.6",
"pidusage": "^4.0.1", "pidusage": "^4.0.1",
"ps-tree": "^1.2.0", "ps-tree": "^1.2.0",
"tsx": "^4.20.5" "tsx": "^4.20.5"

141
pnpm-lock.yaml generated
View File

@@ -20,15 +20,24 @@ importers:
'@push.rocks/smartdaemon': '@push.rocks/smartdaemon':
specifier: ^2.0.9 specifier: ^2.0.9
version: 2.0.9 version: 2.0.9
'@push.rocks/smartfile':
specifier: ^11.2.7
version: 11.2.7
'@push.rocks/smartinteract': '@push.rocks/smartinteract':
specifier: ^2.0.16 specifier: ^2.0.16
version: 2.0.16 version: 2.0.16
'@push.rocks/smartipc': '@push.rocks/smartipc':
specifier: ^2.2.2 specifier: ^2.3.0
version: 2.2.2 version: 2.3.0
'@push.rocks/smartpath': '@push.rocks/smartpath':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
'@types/pidusage':
specifier: ^2.0.5
version: 2.0.5
'@types/ps-tree':
specifier: ^1.1.6
version: 1.1.6
pidusage: pidusage:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
@@ -40,8 +49,8 @@ importers:
version: 4.20.5 version: 4.20.5
devDependencies: devDependencies:
'@git.zone/tsbuild': '@git.zone/tsbuild':
specifier: ^2.6.7 specifier: ^2.6.8
version: 2.6.7 version: 2.6.8
'@git.zone/tsbundle': '@git.zone/tsbundle':
specifier: ^2.5.1 specifier: ^2.5.1
version: 2.5.1 version: 2.5.1
@@ -527,8 +536,8 @@ packages:
'@esm-bundle/chai@4.3.4-fix.0': '@esm-bundle/chai@4.3.4-fix.0':
resolution: {integrity: sha512-26SKdM4uvDWlY8/OOOxSB1AqQWeBosCX3wRYUZO7enTAj03CtVxIiCimYVG2WpULcyV51qapK4qTovwkUr5Mlw==} resolution: {integrity: sha512-26SKdM4uvDWlY8/OOOxSB1AqQWeBosCX3wRYUZO7enTAj03CtVxIiCimYVG2WpULcyV51qapK4qTovwkUr5Mlw==}
'@git.zone/tsbuild@2.6.7': '@git.zone/tsbuild@2.6.8':
resolution: {integrity: sha512-nLRYk1V4gxdEAp5mbLYNdr/in9mFA26L4MPKBKqzASID4lXSYya5sDbLRdDTv+mD0ZRBgdn6e+WMylA0SU4hSw==} resolution: {integrity: sha512-g1z7+MxiYD0xMfuqn8NSWitbfK1OaF0Qolmw7WOmUsHmNF60T1AR02Lo4DtNmnjSpchA+xzDFAQzL1xTcQA39w==}
hasBin: true hasBin: true
'@git.zone/tsbundle@2.5.1': '@git.zone/tsbundle@2.5.1':
@@ -766,8 +775,8 @@ packages:
'@push.rocks/isounique@1.0.5': '@push.rocks/isounique@1.0.5':
resolution: {integrity: sha512-Z0BVqZZOCif1THTbIKWMgg0wxCzt9CyBtBBqQJiZ+jJ0KlQFrQHNHrPt81/LXe/L4x0cxWsn0bpL6W5DNSvNLw==} resolution: {integrity: sha512-Z0BVqZZOCif1THTbIKWMgg0wxCzt9CyBtBBqQJiZ+jJ0KlQFrQHNHrPt81/LXe/L4x0cxWsn0bpL6W5DNSvNLw==}
'@push.rocks/levelcache@3.1.1': '@push.rocks/levelcache@3.2.0':
resolution: {integrity: sha512-+JpDNEt+EuvmbtADGH9SkODxBy+slHDDzs43mAbuMbwpVvi6uNuMK0Mkhrfz9UFpxUSp+cJE/jl/OxdpD0xL1A==} resolution: {integrity: sha512-Ch0Oguta2I0SVi704kHghhBcgfyfS92ua1elRu9d8X1/9LMRYuqvvBAnyXyFxQzI3S8q8QC6EkRdd8CAAYSzRg==}
'@push.rocks/lik@6.1.0': '@push.rocks/lik@6.1.0':
resolution: {integrity: sha512-BoSAIRFNryQ8Sd5EP+35ZBj6vAQ1C60/XjZIO2O65XDyLG8xz7xJ+u5Wm8/fjIJ0WX3h8GkkaCz2tJM34nFT3A==} resolution: {integrity: sha512-BoSAIRFNryQ8Sd5EP+35ZBj6vAQ1C60/XjZIO2O65XDyLG8xz7xJ+u5Wm8/fjIJ0WX3h8GkkaCz2tJM34nFT3A==}
@@ -802,6 +811,9 @@ packages:
'@push.rocks/smartcache@1.0.16': '@push.rocks/smartcache@1.0.16':
resolution: {integrity: sha512-UAXf74eDuH4/RebJhydIbHlYVR3ACYJjniEY/9ZePblu7bIPgwFZqLBE9g1lcKVogbH9yY62dk3rSpgBzenyfQ==} resolution: {integrity: sha512-UAXf74eDuH4/RebJhydIbHlYVR3ACYJjniEY/9ZePblu7bIPgwFZqLBE9g1lcKVogbH9yY62dk3rSpgBzenyfQ==}
'@push.rocks/smartcache@1.0.18':
resolution: {integrity: sha512-3+cmLu9chbnmi4yD4kjlFP/Tn4NReaZIoicEcGTtwbcokTrSDMs3YPdJzIpDZkAs83PW7OcVSHa3Ak5KU5OWzA==}
'@push.rocks/smartchok@1.1.1': '@push.rocks/smartchok@1.1.1':
resolution: {integrity: sha512-WmNigGmn1muBJMANVuJb4F8x3TzgYrnn6YZm6ixTsG+0WFbYevivEwp+J4S7npobLHsR7ynf+Ky8LxRYmsL50A==} resolution: {integrity: sha512-WmNigGmn1muBJMANVuJb4F8x3TzgYrnn6YZm6ixTsG+0WFbYevivEwp+J4S7npobLHsR7ynf+Ky8LxRYmsL50A==}
@@ -829,6 +841,9 @@ packages:
'@push.rocks/smartenv@5.0.13': '@push.rocks/smartenv@5.0.13':
resolution: {integrity: sha512-ACXmUcHZHl2CF2jnVuRw9saRRrZvJblCRs2d+K5aLR1DfkYFX3eA21kcMlKeLisI3aGNbIj9vz/rowN5qkRkfA==} resolution: {integrity: sha512-ACXmUcHZHl2CF2jnVuRw9saRRrZvJblCRs2d+K5aLR1DfkYFX3eA21kcMlKeLisI3aGNbIj9vz/rowN5qkRkfA==}
'@push.rocks/smarterror@2.0.1':
resolution: {integrity: sha512-iCcH1D8tlDJgMFsaJ6lhdOTKhbU0KoprNv9MRP9o7691QOx4JEDXiHtr/lNtxVo8BUtdb9CF6kazaknO9KuORA==}
'@push.rocks/smartexit@1.0.23': '@push.rocks/smartexit@1.0.23':
resolution: {integrity: sha512-WmwKYcwbHBByoABhHHB+PAjr5475AtD/xBh1mDcqPrFsOOUOZq3BBUdpq25wI3ccu/SZB5IwaimiVzadls6HkA==} resolution: {integrity: sha512-WmwKYcwbHBByoABhHHB+PAjr5475AtD/xBh1mDcqPrFsOOUOZq3BBUdpq25wI3ccu/SZB5IwaimiVzadls6HkA==}
@@ -844,9 +859,6 @@ packages:
'@push.rocks/smartfile@10.0.41': '@push.rocks/smartfile@10.0.41':
resolution: {integrity: sha512-xOOy0duI34M2qrJZggpk51EHGXmg9+mBL1Q55tNiQKXzfx89P3coY1EAZG8tvmep3qB712QEKe7T+u04t42Kjg==} resolution: {integrity: sha512-xOOy0duI34M2qrJZggpk51EHGXmg9+mBL1Q55tNiQKXzfx89P3coY1EAZG8tvmep3qB712QEKe7T+u04t42Kjg==}
'@push.rocks/smartfile@11.2.0':
resolution: {integrity: sha512-0Gw6DvCQ2D/BXNN6airSC7hoSBut0p/uNWf2+rqO+D6VLhIJ/QUBvF6xm/LnpPI/zcF8YlDn/GEriInB5DUtEw==}
'@push.rocks/smartfile@11.2.7': '@push.rocks/smartfile@11.2.7':
resolution: {integrity: sha512-8Yp7/sAgPpWJBHohV92ogHWKzRomI5MEbSG6b5W2n18tqwfAmjMed0rQvsvGrSBlnEWCKgoOrYIIZbLO61+J0Q==} resolution: {integrity: sha512-8Yp7/sAgPpWJBHohV92ogHWKzRomI5MEbSG6b5W2n18tqwfAmjMed0rQvsvGrSBlnEWCKgoOrYIIZbLO61+J0Q==}
@@ -865,8 +877,8 @@ packages:
'@push.rocks/smartinteract@2.0.16': '@push.rocks/smartinteract@2.0.16':
resolution: {integrity: sha512-eltvVRRUKBKd77DSFA4DPY2g4V4teZLNe8A93CDy/WglglYcUjxMoLY/b0DFTWCWKYT+yjk6Fe6p0FRrvX9Yvg==} resolution: {integrity: sha512-eltvVRRUKBKd77DSFA4DPY2g4V4teZLNe8A93CDy/WglglYcUjxMoLY/b0DFTWCWKYT+yjk6Fe6p0FRrvX9Yvg==}
'@push.rocks/smartipc@2.2.2': '@push.rocks/smartipc@2.3.0':
resolution: {integrity: sha512-pkWqp2nQH7p5zD9Efh5KNX2O0+gFWL6bxbdd6SdDh4gP8Gb0b3Sn87Tpedghpc/d+LCVql+1pUf6OlvMQpD5Yw==} resolution: {integrity: sha512-/btC/DHf+2PWF6Qiq0oHHP7XHzacgYfHAShIts2ZXS+nhpvSyjucNzB2ErNUPHLMITNXGUSu5Wpt7sfvIQzxJQ==}
'@push.rocks/smartjson@5.0.20': '@push.rocks/smartjson@5.0.20':
resolution: {integrity: sha512-ogGBLyOTluphZVwBYNyjhm5sziPGuiAwWihW07OSRxD4HQUyqj9Ek6r1pqH07JUG5EbtRYivM1Yt1cCwnu3JVQ==} resolution: {integrity: sha512-ogGBLyOTluphZVwBYNyjhm5sziPGuiAwWihW07OSRxD4HQUyqj9Ek6r1pqH07JUG5EbtRYivM1Yt1cCwnu3JVQ==}
@@ -1012,6 +1024,9 @@ packages:
'@push.rocks/tapbundle@6.0.3': '@push.rocks/tapbundle@6.0.3':
resolution: {integrity: sha512-SuP14V6TPdtd1y1CYTvwTKJdpHa7EzY55NfaaEMxW4oRKvHgJiOiPEiR/IrtL9tSiDMSfrx12waTMgZheYaBug==} resolution: {integrity: sha512-SuP14V6TPdtd1y1CYTvwTKJdpHa7EzY55NfaaEMxW4oRKvHgJiOiPEiR/IrtL9tSiDMSfrx12waTMgZheYaBug==}
'@push.rocks/taskbuffer@3.1.10':
resolution: {integrity: sha512-jT+FxRSk0+IP17q9LD1/Ks8GJBn5TZWgLtfnKRHW/LAZ1bHX/2ARZvAV8fm1T4WMU5s7PyId+y4fkoohG/5Nkg==}
'@push.rocks/taskbuffer@3.1.7': '@push.rocks/taskbuffer@3.1.7':
resolution: {integrity: sha512-QktGVJPucqQmW/QNGnscf4FAigT1H7JWKFGFdRuDEaOHKFh9qN+PXG3QY7DtZ4jfXdGLxPN4yAufDuPSAJYFnw==} resolution: {integrity: sha512-QktGVJPucqQmW/QNGnscf4FAigT1H7JWKFGFdRuDEaOHKFh9qN+PXG3QY7DtZ4jfXdGLxPN4yAufDuPSAJYFnw==}
@@ -1647,9 +1662,15 @@ packages:
'@types/parse5@6.0.3': '@types/parse5@6.0.3':
resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==} resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==}
'@types/pidusage@2.0.5':
resolution: {integrity: sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==}
'@types/ping@0.4.4': '@types/ping@0.4.4':
resolution: {integrity: sha512-ifvo6w2f5eJYlXm+HiVx67iJe8WZp87sfa683nlqED5Vnt9Z93onkokNoWqOG21EaE8fMxyKPobE+mkPEyxsdw==} resolution: {integrity: sha512-ifvo6w2f5eJYlXm+HiVx67iJe8WZp87sfa683nlqED5Vnt9Z93onkokNoWqOG21EaE8fMxyKPobE+mkPEyxsdw==}
'@types/ps-tree@1.1.6':
resolution: {integrity: sha512-PtrlVaOaI44/3pl3cvnlK+GxOM3re2526TJvPvh7W+keHIXdV4TE0ylpPBAcvFQCbGitaTXwL9u+RF7qtVeazQ==}
'@types/qs@6.14.0': '@types/qs@6.14.0':
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
@@ -2669,11 +2690,6 @@ packages:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
hasBin: true hasBin: true
glob@11.0.1:
resolution: {integrity: sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==}
engines: {node: 20 || >=22}
hasBin: true
glob@11.0.3: glob@11.0.3:
resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -2989,10 +3005,6 @@ packages:
jackspeak@3.4.3: jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
jackspeak@4.1.0:
resolution: {integrity: sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==}
engines: {node: 20 || >=22}
jackspeak@4.1.1: jackspeak@4.1.1:
resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -3412,10 +3424,6 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'} engines: {node: '>=4'}
minimatch@10.0.1:
resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==}
engines: {node: 20 || >=22}
minimatch@10.0.3: minimatch@10.0.3:
resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -5612,7 +5620,7 @@ snapshots:
dependencies: dependencies:
'@types/chai': 4.3.20 '@types/chai': 4.3.20
'@git.zone/tsbuild@2.6.7': '@git.zone/tsbuild@2.6.8':
dependencies: dependencies:
'@git.zone/tspublish': 1.10.3 '@git.zone/tspublish': 1.10.3
'@push.rocks/early': 4.0.4 '@push.rocks/early': 4.0.4
@@ -5665,7 +5673,7 @@ snapshots:
'@git.zone/tsrun@1.3.3': '@git.zone/tsrun@1.3.3':
dependencies: dependencies:
'@push.rocks/smartfile': 11.2.0 '@push.rocks/smartfile': 11.2.7
'@push.rocks/smartshell': 3.2.3 '@push.rocks/smartshell': 3.2.3
tsx: 4.20.5 tsx: 4.20.5
@@ -6028,21 +6036,21 @@ snapshots:
'@push.rocks/isounique@1.0.5': {} '@push.rocks/isounique@1.0.5': {}
'@push.rocks/levelcache@3.1.1': '@push.rocks/levelcache@3.2.0':
dependencies: dependencies:
'@push.rocks/lik': 6.2.2 '@push.rocks/lik': 6.2.2
'@push.rocks/smartbucket': 3.3.10 '@push.rocks/smartbucket': 3.3.10
'@push.rocks/smartcache': 1.0.16 '@push.rocks/smartcache': 1.0.18
'@push.rocks/smartenv': 5.0.13 '@push.rocks/smartenv': 5.0.13
'@push.rocks/smartexit': 1.0.23 '@push.rocks/smartexit': 1.0.23
'@push.rocks/smartfile': 11.2.7 '@push.rocks/smartfile': 11.2.7
'@push.rocks/smartjson': 5.0.20 '@push.rocks/smartjson': 5.0.20
'@push.rocks/smartpath': 5.1.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartstring': 4.0.15 '@push.rocks/smartstring': 4.0.15
'@push.rocks/smartunique': 3.0.9 '@push.rocks/smartunique': 3.0.9
'@push.rocks/taskbuffer': 3.1.7 '@push.rocks/taskbuffer': 3.1.10
'@tsclass/tsclass': 4.4.4 '@tsclass/tsclass': 9.2.0
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - aws-crt
@@ -6171,6 +6179,14 @@ snapshots:
'@pushrocks/smartpromise': 3.1.10 '@pushrocks/smartpromise': 3.1.10
'@pushrocks/smarttime': 4.0.1 '@pushrocks/smarttime': 4.0.1
'@push.rocks/smartcache@1.0.18':
dependencies:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smarterror': 2.0.1
'@push.rocks/smarthash': 3.2.3
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smarttime': 4.1.1
'@push.rocks/smartchok@1.1.1': '@push.rocks/smartchok@1.1.1':
dependencies: dependencies:
'@push.rocks/lik': 6.2.2 '@push.rocks/lik': 6.2.2
@@ -6250,6 +6266,11 @@ snapshots:
dependencies: dependencies:
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smarterror@2.0.1':
dependencies:
clean-stack: 1.3.0
make-error-cause: 2.3.0
'@push.rocks/smartexit@1.0.23': '@push.rocks/smartexit@1.0.23':
dependencies: dependencies:
'@push.rocks/lik': 6.1.0 '@push.rocks/lik': 6.1.0
@@ -6290,25 +6311,6 @@ snapshots:
glob: 10.4.5 glob: 10.4.5
js-yaml: 4.1.0 js-yaml: 4.1.0
'@push.rocks/smartfile@11.2.0':
dependencies:
'@push.rocks/lik': 6.1.0
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartfile-interfaces': 1.0.7
'@push.rocks/smarthash': 3.0.4
'@push.rocks/smartjson': 5.0.20
'@push.rocks/smartmime': 2.0.4
'@push.rocks/smartpath': 5.1.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 2.0.23
'@push.rocks/smartstream': 3.2.5
'@types/fs-extra': 11.0.4
'@types/glob': 8.1.0
'@types/js-yaml': 4.0.9
fs-extra: 11.3.0
glob: 11.0.1
js-yaml: 4.1.0
'@push.rocks/smartfile@11.2.7': '@push.rocks/smartfile@11.2.7':
dependencies: dependencies:
'@push.rocks/lik': 6.2.2 '@push.rocks/lik': 6.2.2
@@ -6358,7 +6360,7 @@ snapshots:
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
inquirer: 11.1.0 inquirer: 11.1.0
'@push.rocks/smartipc@2.2.2': '@push.rocks/smartipc@2.3.0':
dependencies: dependencies:
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartrx': 3.0.10 '@push.rocks/smartrx': 3.0.10
@@ -6475,7 +6477,7 @@ snapshots:
'@push.rocks/smartnpm@2.0.6': '@push.rocks/smartnpm@2.0.6':
dependencies: dependencies:
'@push.rocks/consolecolor': 2.0.3 '@push.rocks/consolecolor': 2.0.3
'@push.rocks/levelcache': 3.1.1 '@push.rocks/levelcache': 3.2.0
'@push.rocks/smartarchive': 4.2.2 '@push.rocks/smartarchive': 4.2.2
'@push.rocks/smartfile': 11.2.7 '@push.rocks/smartfile': 11.2.7
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
@@ -6777,6 +6779,16 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
'@push.rocks/taskbuffer@3.1.10':
dependencies:
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.1.8
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smarttime': 4.1.1
'@push.rocks/smartunique': 3.0.9
'@push.rocks/taskbuffer@3.1.7': '@push.rocks/taskbuffer@3.1.7':
dependencies: dependencies:
'@push.rocks/lik': 6.2.2 '@push.rocks/lik': 6.2.2
@@ -7624,8 +7636,12 @@ snapshots:
'@types/parse5@6.0.3': {} '@types/parse5@6.0.3': {}
'@types/pidusage@2.0.5': {}
'@types/ping@0.4.4': {} '@types/ping@0.4.4': {}
'@types/ps-tree@1.1.6': {}
'@types/qs@6.14.0': {} '@types/qs@6.14.0': {}
'@types/randomatic@3.1.5': {} '@types/randomatic@3.1.5': {}
@@ -8736,15 +8752,6 @@ snapshots:
package-json-from-dist: 1.0.1 package-json-from-dist: 1.0.1
path-scurry: 1.11.1 path-scurry: 1.11.1
glob@11.0.1:
dependencies:
foreground-child: 3.3.1
jackspeak: 4.1.0
minimatch: 10.0.1
minipass: 7.1.2
package-json-from-dist: 1.0.1
path-scurry: 2.0.0
glob@11.0.3: glob@11.0.3:
dependencies: dependencies:
foreground-child: 3.3.1 foreground-child: 3.3.1
@@ -9093,10 +9100,6 @@ snapshots:
optionalDependencies: optionalDependencies:
'@pkgjs/parseargs': 0.11.0 '@pkgjs/parseargs': 0.11.0
jackspeak@4.1.0:
dependencies:
'@isaacs/cliui': 8.0.2
jackspeak@4.1.1: jackspeak@4.1.1:
dependencies: dependencies:
'@isaacs/cliui': 8.0.2 '@isaacs/cliui': 8.0.2
@@ -9729,10 +9732,6 @@ snapshots:
min-indent@1.0.1: {} min-indent@1.0.1: {}
minimatch@10.0.1:
dependencies:
brace-expansion: 2.0.1
minimatch@10.0.3: minimatch@10.0.3:
dependencies: dependencies:
'@isaacs/brace-expansion': 5.0.0 '@isaacs/brace-expansion': 5.0.0

481
readme.md
View File

@@ -2,118 +2,133 @@
**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 designed specifically for TypeScript and Node.js applications. Built for developers who need reliable process management without the complexity.
## 🎯 What TSPM Does ## 🎯 What is TSPM?
TSPM is your production-ready process manager that handles the hard parts of running Node.js applications: 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.
- **Automatic Memory Management** - Set memory limits and let TSPM handle the rest ### ✨ Key Features
- **Smart Auto-Restart** - Crashed processes come back automatically (when you want them to)
- **File Watching** - Auto-restart on file changes during development - **🧠 Smart Memory Management** - Tracks memory including child processes, enforces limits, and auto-restarts when exceeded
- **Process Groups** - Track parent and child processes together - **💾 Persistent Log Storage** - Keeps 10MB of logs in memory, persists to disk on restart/stop/error
- **Daemon Architecture** - Survives terminal sessions with a persistent background daemon - **🔄 Intelligent Auto-Restart** - Automatically restarts crashed processes with configurable policies
- **Beautiful CLI** - Clean, informative terminal output with real-time status - **👀 File Watching** - Auto-restart on file changes for seamless development
- **Structured Logging** - Capture and manage stdout/stderr with intelligent buffering - **🌳 Process Group Tracking** - Monitors parent and all child processes as a unit
- **Zero Config** - Works out of the box, customize when you need to - **🏗️ 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
## 📦 Installation ## 📦 Installation
```bash ```bash
# Install globally # Install globally (recommended)
npm install -g @git.zone/tspm npm install -g @git.zone/tspm
# Or with pnpm (recommended) # Or with pnpm
pnpm add -g @git.zone/tspm pnpm add -g @git.zone/tspm
# Or use in your project # Or as a dev dependency
npm install --save-dev @git.zone/tspm npm install --save-dev @git.zone/tspm
``` ```
## 🚀 Quick Start ## 🚀 Quick Start
```bash ```bash
# Start the daemon (happens automatically on first use) # Add a process (creates config without starting)
tspm daemon start tspm add "node server.js" --name my-server --memory 1GB
# Start a process # Start the process (by name or id)
tspm start server.js --name my-server tspm start name:my-server
# or
tspm start id:1
# Start with memory limit # Or add and start in one go
tspm start app.js --memory 512MB --name my-app tspm add "node app.js" --name my-app
tspm start name:my-app
# Start with file watching (great for development)
tspm start dev.js --watch --name dev-server
# List all processes # List all processes
tspm list tspm list
# Check process details
tspm describe my-server
# View logs # View logs
tspm logs my-server --lines 100 tspm logs name:my-app
# Stop a process # Stop a process
tspm stop my-server tspm stop name:my-app
# Restart a process
tspm restart my-server
``` ```
## 📋 Command Reference ## 📋 Commands
### Process Management ### Process Management
#### `tspm start <script> [options]` #### `tspm add <command> [options]`
Start a new process with automatic monitoring and management. Add a new process configuration without starting it. This is the recommended way to register processes.
**Options:** **Options:**
- `--name <name>` - Custom name for the process (required)
- `--name <name>` - Custom name for the process (default: script name)
- `--memory <size>` - Memory limit (e.g., "512MB", "2GB", default: 512MB) - `--memory <size>` - Memory limit (e.g., "512MB", "2GB", default: 512MB)
- `--cwd <path>` - Working directory (default: current directory) - `--cwd <path>` - Working directory (default: current directory)
- `--watch` - Enable file watching for auto-restart - `--watch` - Enable file watching for auto-restart
- `--watch-paths <paths>` - Comma-separated paths to watch (with --watch) - `--watch-paths <paths>` - Comma-separated paths to watch
- `--autorestart` - Auto-restart on crash (default: true) - `--autorestart` - Auto-restart on crash (default: true)
**Examples:** **Examples:**
```bash ```bash
# Simple start # Add a simple Node.js app
tspm start server.js tspm add "node server.js" --name api-server
# Production setup with 2GB memory # Add with 2GB memory limit
tspm start app.js --name production-api --memory 2GB tspm add "node app.js" --name production-api --memory 2GB
# Development with watching # Add TypeScript app with watching
tspm start dev-server.js --watch --watch-paths "src,config" --name dev tspm add "tsx watch src/index.ts" --name dev-server --watch --watch-paths "src,config"
# Custom working directory # Add without auto-restart
tspm start ../other-project/index.js --cwd ../other-project --name other tspm add "node worker.js" --name one-time-job --autorestart false
``` ```
#### `tspm stop <id>` #### `tspm start <id|id:N|name:LABEL>`
Start a previously added process by its ID or name.
```bash
tspm start name:my-server
tspm start id:1 # Or a bare numeric id: tspm start 1
```
#### `tspm stop <id|id:N|name:LABEL>`
Gracefully stop a running process (SIGTERM → SIGKILL after timeout). Gracefully stop a running process (SIGTERM → SIGKILL after timeout).
```bash ```bash
tspm stop my-server tspm stop name:my-server
``` ```
#### `tspm restart <id>` #### `tspm restart <id|id:N|name:LABEL>`
Stop and restart a process with the same configuration. Stop and restart a process with the same configuration.
```bash ```bash
tspm restart my-server tspm restart name:my-server
``` ```
#### `tspm delete <id>` #### `tspm delete <id|id:N|name:LABEL>` / `tspm remove <id|id:N|name:LABEL>`
Stop and remove a process from TSPM management. Stop and remove a process from TSPM management. Also deletes persisted logs.
```bash ```bash
tspm delete old-server tspm delete name:old-server
tspm remove name:old-server # Alias for delete (daemon handles delete)
```
#### `tspm edit <id>`
Interactively edit a process configuration.
```bash
tspm edit my-server
# Opens interactive prompts to modify name, command, memory, etc.
``` ```
### Monitoring & Information ### Monitoring & Information
@@ -126,20 +141,21 @@ Display all managed processes in a beautiful table.
tspm list tspm list
# Output: # Output:
┌─────────┬─────────────┬───────────┬───────────┬──────────┐ ┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────
│ ID │ Name │ Status │ Memory │ Restarts │ │ ID │ Name │ Status │ PID │ Memory │ Restarts │
├─────────┼─────────────┼───────────┼───────────┼──────────┤ ├─────────┼─────────────┼───────────┼───────────┼──────────┼──────────
my-app │ my-app │ online │ 245.3 MB 0 1 │ my-app │ online │ 45123 245.3 MB │ 0
worker │ worker │ online │ 128.7 MB 2 2 │ worker │ online │ 45456 128.7 MB │ 2
└─────────┴─────────────┴───────────┴───────────┴──────────┘ 3 │ api-server │ stopped │ - │ 0 B │ 5
└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┘
``` ```
#### `tspm describe <id>` #### `tspm describe <id|id:N|name:LABEL>`
Get detailed information about a specific process. Get detailed information about a specific process.
```bash ```bash
tspm describe my-server tspm describe name:my-server
# Output: # Output:
Process Details: my-server Process Details: my-server
@@ -147,31 +163,51 @@ Process Details: my-server
Status: online Status: online
PID: 45123 PID: 45123
Memory: 245.3 MB Memory: 245.3 MB
CPU: 2.3%
Uptime: 3600s Uptime: 3600s
Restarts: 0 Restarts: 0
Configuration: Configuration:
Command: server.js ────────────────────────────────────────
Command: node server.js
Directory: /home/user/project Directory: /home/user/project
Memory Limit: 2 GB Memory Limit: 2 GB
Auto-restart: true Auto-restart: true
Watch: enabled Watch: disabled
Watch Paths: src, config
``` ```
#### `tspm logs <id> [options]` #### `tspm logs <id|id:N|name:LABEL> [options]`
View process logs (stdout and stderr). View and stream process logs (stdout, stderr, and system messages).
**Options:** **Options:**
- `--lines <n>` Number of lines to show (default: 50)
- `--lines <n>` - Number of lines to display (default: 50) - `--since <dur>` Only show logs since duration (e.g., `10m`, `2h`, `1d`; units: `ms|s|m|h|d`)
- `--stderr-only` Only show stderr logs
- `--stdout-only` Only show stdout logs
- `--ndjson` Output each log as JSON line (timestamp in ms)
- `--follow` Stream logs in real-time (like `tail -f`)
```bash ```bash
tspm logs my-server --lines 100 # View last 50 lines
tspm logs name:my-server
# View last 100 lines
tspm logs name:my-server --lines 100
# Only stderr for the last 10 minutes (as NDJSON)
tspm logs name:my-server --since 10m --stderr-only --ndjson
# Follow logs in real time (prints recent lines, then streams backlog incrementally and live logs)
tspm logs name:my-server --follow
# Follow only stdout since 2h ago
tspm logs name:my-server --follow --since 2h --stdout-only
``` ```
Notes:
- Follow mode prints a small recent backlog, then streams older entries incrementally (to avoid large payloads) and continues with live logs.
- Log sequences are restart-aware; TSPM detects run changes and keeps output consistent across restarts.
### Batch Operations ### Batch Operations
#### `tspm start-all` #### `tspm start-all`
@@ -180,6 +216,10 @@ Start all saved processes at once.
```bash ```bash
tspm start-all tspm start-all
# ✓ Started 3 processes:
# - my-app
# - worker
# - api-server
``` ```
#### `tspm stop-all` #### `tspm stop-all`
@@ -188,6 +228,7 @@ Stop all running processes.
```bash ```bash
tspm stop-all tspm stop-all
# ✓ Stopped 3 processes
``` ```
#### `tspm restart-all` #### `tspm restart-all`
@@ -196,24 +237,49 @@ Restart all running processes.
```bash ```bash
tspm restart-all 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.
``` ```
### Daemon Management ### Daemon Management
The TSPM daemon runs in the background and manages all your processes. It starts automatically when needed.
#### `tspm daemon start` #### `tspm daemon start`
Start the TSPM daemon (happens automatically on first command). Manually start the TSPM daemon (usually automatic).
```bash ```bash
tspm daemon start tspm daemon start
# ✓ TSPM daemon started successfully
``` ```
#### `tspm daemon stop` #### `tspm daemon stop`
Stop the TSPM daemon and all managed processes. Stop the daemon and all managed processes.
```bash ```bash
tspm daemon stop tspm daemon stop
# ✓ TSPM daemon stopped successfully
```
#### `tspm daemon restart`
Restart the daemon (preserves running processes).
```bash
tspm daemon restart
# ✓ TSPM daemon restarted successfully
``` ```
#### `tspm daemon status` #### `tspm daemon status`
@@ -230,75 +296,187 @@ Status: running
PID: 12345 PID: 12345
Uptime: 86400s Uptime: 86400s
Processes: 5 Processes: 5
Memory: 45.2 MB Socket: /home/user/.tspm/tspm.sock
CPU: 0.1% ```
#### Version check and service refresh
Check CLI vs daemon versions and refresh the systemd service if they differ:
```bash
tspm -v
# tspm CLI: 5.x.y
# Daemon: running v5.x.z (pid 1234)
# Version mismatch detected → optionally refresh the systemd service (equivalent to `tspm disable && tspm enable`).
```
This is helpful after upgrades where the system service still references an older CLI path.
### 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
``` ```
## 🏗️ Architecture ## 🏗️ Architecture
TSPM uses a three-tier architecture for maximum reliability: TSPM uses a robust three-tier architecture:
1. **ProcessWrapper** - Low-level process management with stream handling ```
2. **ProcessMonitor** - Adds monitoring, memory limits, and auto-restart logic ┌─────────────────────────────────────────┐
3. **Tspm Core** - High-level orchestration with configuration persistence │ CLI Interface │
│ (tspm commands) │
└────────────────┬────────────────────────┘
│ Unix Socket IPC
┌────────────────▼────────────────────────┐
│ TSPM Daemon │
│ (Background Service) │
│ ┌──────────────────────────────────┐ │
│ │ ProcessManager │ │
│ │ - Configuration persistence │ │
│ │ - Process lifecycle │ │
│ │ - Desired state management │ │
│ └────────────┬─────────────────────┘ │
│ │ │
│ ┌────────────▼─────────────────────┐ │
│ │ ProcessMonitor │ │
│ │ - Memory tracking & limits │ │
│ │ - Auto-restart logic │ │
│ │ - Log persistence (10MB) │ │
│ │ - File watching │ │
│ └────────────┬─────────────────────┘ │
│ │ │
│ ┌────────────▼─────────────────────┐ │
│ │ ProcessWrapper │ │
│ │ - Process spawning │ │
│ │ - Stream handling │ │
│ │ - Signal management │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
The daemon architecture ensures your processes keep running even after you close your terminal. All process communication happens through a robust IPC (Inter-Process Communication) system. ### Key Components
## 🎮 Programmatic Usage - **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
TSPM can also be used as a library in your Node.js applications: ## 🎮 Programmatic API
Use TSPM as a library in your Node.js applications:
```typescript ```typescript
import { Tspm } from '@git.zone/tspm'; import { TspmIpcClient } from '@git.zone/tspm/client';
const manager = new Tspm(); const client = new TspmIpcClient();
await client.connect();
// Start a process // Add and start a process
const processId = await manager.start({ const { id } = await client.request('add', {
id: 'worker',
name: 'Background Worker',
command: 'node worker.js', command: 'node worker.js',
name: 'background-worker',
projectDir: process.cwd(), projectDir: process.cwd(),
memoryLimitBytes: 512 * 1024 * 1024, // 512MB memoryLimit: 512 * 1024 * 1024, // 512MB in bytes
autorestart: true, autorestart: true,
watch: false, watch: false,
}); });
// Monitor process await client.request('start', { id });
const info = await manager.getProcessInfo(processId);
console.log(`Process ${info.id} is ${info.status}`);
// Stop process // Get process info
await manager.stop(processId); const { processInfo } = await client.request('describe', { id });
console.log(`Worker status: ${processInfo.status}`);
console.log(`Memory usage: ${processInfo.memory} bytes`);
// Get logs
const { logs } = await client.request('logs', { id, limit: 100 });
logs.forEach(log => {
console.log(`[${log.timestamp}] ${log.message}`);
});
// Clean up
await client.request('stop', { id });
await client.disconnect();
``` ```
## 🔧 Advanced Features ## 🔧 Advanced Features
### Memory Limit Enforcement ### Memory Management
TSPM tracks memory usage including all child processes spawned by your application. When a process exceeds its memory limit, it's gracefully restarted. 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
### Process Group Tracking ### Log Persistence
Using `ps-tree`, TSPM monitors not just your main process but all child processes it spawns, ensuring complete cleanup on stop/restart. 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
### Intelligent Logging ### Process Groups
Logs are buffered and managed efficiently, preventing memory issues from excessive output while ensuring you don't lose important information. Full process tree management:
- Tracks parent and all child processes
- Ensures complete cleanup on stop
- Accurate memory tracking across process trees
- No orphaned processes
### Graceful Shutdown ### Graceful Shutdown
Processes receive SIGTERM first, allowing them to clean up. After a timeout, SIGKILL ensures termination. 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
### Configuration Persistence ### File Watching
Process configurations are saved, allowing you to restart all processes after a system reboot with a single command. 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 ## 🛠️ Development
```bash ```bash
# Clone the repository # Clone the repository
git clone https://code.foss.global/git.zone/tspm.git git clone https://code.foss.global/git.zone/tspm.git
cd tspm
# Install dependencies # Install dependencies
pnpm install pnpm install
@@ -309,42 +487,109 @@ pnpm test
# Build the project # Build the project
pnpm build pnpm build
# Start development # Run in development
pnpm start 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
```
## 🐛 Debugging ## 🐛 Debugging
Enable debug mode for verbose logging: Enable verbose logging for troubleshooting:
```bash ```bash
# Enable debug mode
export TSPM_DEBUG=true export TSPM_DEBUG=true
tspm list tspm list
# Check daemon logs
tail -f /tmp/daemon-stderr.log
# Force daemon restart
tspm daemon restart
``` ```
## 📊 Performance Common issues:
TSPM is designed to be lightweight and efficient: - **"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>`
- Minimal CPU overhead (typically < 0.5%) ## 🎯 Targeting Processes (IDs and Names)
- Small memory footprint (~30-50MB for the daemon)
- Fast process startup and shutdown
- Efficient log buffering and rotation
## 🤝 Why TSPM? Most process commands accept the following target formats:
Unlike general-purpose process managers, TSPM is built specifically for the TypeScript/Node.js ecosystem: - Numeric ID: `tspm start 1`
- Explicit ID: `tspm start id:1`
- Explicit name: `tspm start name:api-server`
- **TypeScript First** - Written in TypeScript, for TypeScript projects Notes:
- **ESM Native** - Full support for ES modules - Names must be used with the `name:` prefix.
- **Developer Friendly** - Beautiful CLI output and helpful error messages - If multiple processes share the same name, the CLI will report the ambiguous matches. Use `id:N` to disambiguate.
- **Production Ready** - Battle-tested memory management and error handling - Use `tspm search <query>` to discover IDs by name or ID fragments.
- **No Configuration Required** - Sensible defaults that just work
- **Modern Architecture** - Async/await throughout, no callback hell ### `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
| 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 |
| Dependencies | Minimal | Heavy |
| ESM Support | Native | Partial |
| Config Format | Simple JSON | Complex ecosystem |
### 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
## License and Legal Information ## 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 that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. **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.

View File

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

View File

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

View File

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

View File

@@ -22,13 +22,14 @@ export function registerDefaultCommand(smartcli: plugins.smartcli.Smartcli) {
); );
console.log(' disable Disable TSPM system service'); console.log(' disable Disable TSPM system service');
console.log('\nProcess Commands:'); console.log('\nProcess Commands:');
console.log(' start <script> Start a process'); console.log(' start <id|id:N|name:LBL> Start a process');
console.log(' list List all processes'); console.log(' list List all processes');
console.log(' stop <id> Stop a process'); console.log(' stop <id|id:N|name:LBL> Stop a process');
console.log(' restart <id> Restart a process'); console.log(' restart <id|id:N|name:LBL> Restart a process');
console.log(' delete <id> Delete a process'); console.log(' delete <id|id:N|name:LBL> Delete a process');
console.log(' describe <id> Show details for a process'); console.log(' describe <id|id:N|name:LBL> Show details for a process');
console.log(' logs <id> Show logs for a process'); console.log(' logs <id|id:N|name:LBL> Show logs for a process');
console.log(' search <query> Find processes by id/name');
console.log(' start-all Start all saved processes'); console.log(' start-all Start all saved processes');
console.log(' stop-all Stop all processes'); console.log(' stop-all Stop all processes');
console.log(' restart-all Restart all processes'); console.log(' restart-all Restart all processes');
@@ -74,7 +75,7 @@ export function registerDefaultCommand(smartcli: plugins.smartcli.Smartcli) {
const resetColor = '\x1b[0m'; const resetColor = '\x1b[0m';
console.log( console.log(
`${pad(proc.id, 7)}${pad(proc.id, 11)}${statusColor}${pad(proc.status, 9)}${resetColor}${pad(formatMemory(proc.memory), 9)}${pad(proc.restarts.toString(), 8)}`, `${pad(String(proc.id), 7)}${pad(String(proc.id), 11)}${statusColor}${pad(proc.status, 9)}${resetColor}${pad(formatMemory(proc.memory), 9)}${pad(proc.restarts.toString(), 8)}`,
); );
} }

View File

@@ -69,6 +69,33 @@ export function registerAddCommand(smartcli: plugins.smartcli.Smartcli) {
if (watchPaths) console.log(` Watch paths: ${watchPaths.join(',')}`); if (watchPaths) console.log(` Watch paths: ${watchPaths.join(',')}`);
} }
// Capture essential environment variables from the CLI environment
// so processes have access to the same environment they were added with
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];
}
});
const response = await tspmIpcClient.request('add', { const response = await tspmIpcClient.request('add', {
config: { config: {
name, name,
@@ -76,6 +103,7 @@ export function registerAddCommand(smartcli: plugins.smartcli.Smartcli) {
args: cmdArgs, args: cmdArgs,
projectDir, projectDir,
memoryLimitBytes: memoryLimit, memoryLimitBytes: memoryLimit,
env: essentialEnvVars,
autorestart, autorestart,
watch, watch,
watchPaths, watchPaths,

View File

@@ -8,23 +8,25 @@ export function registerDeleteCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
['delete', 'remove'], ['delete', 'remove'],
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const id = argvArg._[1]; const target = argvArg._[1];
if (!id) { if (!target) {
console.error('Error: Please provide a process ID'); console.error('Error: Please provide a process target');
console.log('Usage: tspm delete <id> | tspm remove <id>'); console.log('Usage: tspm delete <id|id:N|name:LABEL> | tspm remove <id|id:N|name:LABEL>');
return; return;
} }
// Determine if command was 'remove' to use the new IPC route, otherwise 'delete' // Determine if command was 'remove' to use the new IPC route, otherwise 'delete'
const cmd = String(argvArg._[0]); const cmd = String(argvArg._[0]);
const useRemove = cmd === 'remove'; const isRemoveAlias = cmd === 'remove';
console.log(`${useRemove ? 'Removing' : 'Deleting'} process: ${id}`); console.log(`${isRemoveAlias ? 'Removing' : 'Deleting'} process: ${target}`);
const response = await tspmIpcClient.request(useRemove ? 'remove' : 'delete', { id } as any); const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
// Always call daemon 'delete'; 'remove' is CLI alias only
const response = await tspmIpcClient.request('delete', { id: resolved.id } as any);
if (response.success) { if (response.success) {
console.log(`${response.message || (useRemove ? 'Removed successfully' : 'Deleted successfully')}`); console.log(`${response.message || (isRemoveAlias ? 'Removed successfully' : 'Deleted successfully')}`);
} else { } else {
console.error(`✗ Failed to ${useRemove ? 'remove' : 'delete'} process: ${response.message}`); console.error(`✗ Failed to ${isRemoveAlias ? 'remove' : 'delete'} process: ${response.message}`);
} }
}, },
{ actionLabel: 'delete/remove process' }, { actionLabel: 'delete/remove process' },

View File

@@ -9,16 +9,17 @@ export function registerDescribeCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
'describe', 'describe',
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const id = argvArg._[1]; const target = argvArg._[1];
if (!id) { if (!target) {
console.error('Error: Please provide a process ID'); console.error('Error: Please provide a process target');
console.log('Usage: tspm describe <id>'); console.log('Usage: tspm describe <id | id:N | name:LABEL>');
return; return;
} }
const response = await tspmIpcClient.request('describe', { id }); const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
const response = await tspmIpcClient.request('describe', { id: resolved.id });
console.log(`Process Details: ${id}`); console.log(`Process Details: ${response.config.name || resolved.id}`);
console.log('─'.repeat(40)); console.log('─'.repeat(40));
console.log(`Status: ${response.processInfo.status}`); console.log(`Status: ${response.processInfo.status}`);
console.log(`PID: ${response.processInfo.pid || 'N/A'}`); console.log(`PID: ${response.processInfo.pid || 'N/A'}`);

View File

@@ -0,0 +1,74 @@
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 { formatMemory, parseMemoryString } from '../../helpers/memory.js';
export function registerEditCommand(smartcli: plugins.smartcli.Smartcli) {
registerIpcCommand(
smartcli,
'edit',
async (argvArg: CliArguments) => {
const target = argvArg._[1];
if (!target) {
console.error('Error: Please provide a process target to edit');
console.log('Usage: tspm edit <id | id:N | name:LABEL>');
return;
}
// Resolve and load current config
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');
},
{ actionLabel: 'edit process config' },
);
}

View File

@@ -20,13 +20,13 @@ export function registerListCommand(smartcli: plugins.smartcli.Smartcli) {
console.log('Process List:'); console.log('Process List:');
console.log( console.log(
'┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────┐', '┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────┬─────────┐',
); );
console.log( console.log(
'│ ID │ Name │ Status │ PID │ Memory │ Restarts │', '│ ID │ Name │ Status │ PID │ Memory │ CPU │ Restarts │',
); );
console.log( console.log(
'├─────────┼─────────────┼───────────┼───────────┼──────────┼──────────┤', '├─────────┼─────────────┼───────────┼───────────┼──────────┼──────────┼──────────┤',
); );
for (const proc of processes) { for (const proc of processes) {
@@ -38,13 +38,18 @@ export function registerListCommand(smartcli: plugins.smartcli.Smartcli) {
: '\x1b[33m'; : '\x1b[33m';
const resetColor = '\x1b[0m'; 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( console.log(
`${pad(proc.id, 7)}${pad(proc.id, 11)}${statusColor}${pad(proc.status, 9)}${resetColor}${pad((proc.pid || '-').toString(), 9)}${pad(formatMemory(proc.memory), 8)}${pad(proc.restarts.toString(), 8)}`, `${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( console.log(
'└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┘', '└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┴──────────┘',
); );
}, },
{ actionLabel: 'list processes' }, { actionLabel: 'list processes' },

View File

@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.js';
import { tspmIpcClient } from '../../../client/tspm.ipcclient.js'; import { tspmIpcClient } from '../../../client/tspm.ipcclient.js';
import type { CliArguments } from '../../types.js'; import type { CliArguments } from '../../types.js';
import { registerIpcCommand } from '../../registration/index.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 { formatLog } from '../../helpers/formatting.js';
import { withStreamingLifecycle } from '../../helpers/lifecycle.js'; import { withStreamingLifecycle } from '../../helpers/lifecycle.js';
@@ -11,26 +11,97 @@ export function registerLogsCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
'logs', 'logs',
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const id = argvArg._[1]; const target = argvArg._[1];
if (!id) { if (!target) {
console.error('Error: Please provide a process ID'); console.error('Error: Please provide a process target');
console.log('Usage: tspm logs <id> [options]'); console.log('Usage: tspm logs <id | id:N | name:LABEL> [options]');
console.log('\nOptions:'); console.log('\nOptions:');
console.log(' --lines <n> Number of lines to show (default: 50)'); console.log(' --lines <n> Number of lines to show (default: 50)');
console.log(' --follow Stream logs in real-time (like tail -f)'); 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; return;
} }
const lines = getNumber(argvArg, 'lines', 50); const lines = getNumber(argvArg, 'lines', 50);
const follow = getBool(argvArg, 'follow', 'f'); 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 response = await tspmIpcClient.request('getLogs', { id, lines }); 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: sinceTime ? 0 : lines });
if (!follow) { if (!follow) {
// One-shot mode - auto-disconnect handled by registerIpcCommand // 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)); 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'
? '[OUT]'
: log.type === 'stderr'
? '[ERR]'
: '[SYS]';
console.log(`${timestamp} ${prefix} ${log.message}`);
}
}
return;
}
// Streaming mode
console.log(`Logs for process: ${resolved.name || id} (streaming...)`);
console.log('─'.repeat(60));
// Prepare backlog printing state and stream handler
let lastSeq = 0;
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 timestamp = new Date(log.timestamp).toLocaleTimeString();
const prefix = const prefix =
log.type === 'stdout' log.type === 'stdout'
@@ -40,43 +111,60 @@ export function registerLogsCommand(smartcli: plugins.smartcli.Smartcli) {
: '[SYS]'; : '[SYS]';
console.log(`${timestamp} ${prefix} ${log.message}`); console.log(`${timestamp} ${prefix} ${log.message}`);
} }
return; };
}
// Streaming mode // Print initial backlog (already fetched via getLogs)
console.log(`Logs for process: ${id} (streaming...)`);
console.log('─'.repeat(60));
let lastSeq = 0;
for (const log of response.logs) { for (const log of response.logs) {
const timestamp = new Date(log.timestamp).toLocaleTimeString(); printLog(log);
const prefix =
log.type === 'stdout'
? '[OUT]'
: log.type === 'stderr'
? '[ERR]'
: '[SYS]';
console.log(`${timestamp} ${prefix} ${log.message}`);
if (log.seq !== undefined) lastSeq = Math.max(lastSeq, log.seq); if (log.seq !== undefined) lastSeq = Math.max(lastSeq, log.seq);
if ((log as any).runId) lastRunId = (log as any).runId;
} }
// 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}`,
);
}
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( await withStreamingLifecycle(
async () => { 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) => { 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) return;
if (log.seq !== undefined && log.seq > lastSeq + 1) { if (log.seq !== undefined && log.seq > lastSeq + 1) {
console.log( console.log(
`[WARNING] Log gap detected: expected seq ${lastSeq + 1}, got ${log.seq}`, `[WARNING] Log gap detected: expected seq ${lastSeq + 1}, got ${log.seq}`,
); );
} }
const timestamp = new Date(log.timestamp).toLocaleTimeString(); printLog(log);
const prefix =
log.type === 'stdout'
? '[OUT]'
: log.type === 'stderr'
? '[ERR]'
: '[SYS]';
console.log(`${timestamp} ${prefix} ${log.message}`);
if (log.seq !== undefined) lastSeq = log.seq; if (log.seq !== undefined) lastSeq = log.seq;
}); });
}, },

View File

@@ -10,9 +10,9 @@ export function registerRestartCommand(smartcli: plugins.smartcli.Smartcli) {
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const arg = argvArg._[1]; const arg = argvArg._[1];
if (!arg) { if (!arg) {
console.error('Error: Please provide a process ID or "all"'); console.error('Error: Please provide a process target or "all"');
console.log('Usage:'); console.log('Usage:');
console.log(' tspm restart <id>'); console.log(' tspm restart <id | id:N | name:LABEL>');
console.log(' tspm restart all'); console.log(' tspm restart all');
return; return;
} }
@@ -32,12 +32,13 @@ export function registerRestartCommand(smartcli: plugins.smartcli.Smartcli) {
return; return;
} }
const id = String(arg); const target = String(arg);
console.log(`Restarting process: ${id}`); console.log(`Restarting process: ${target}`);
const response = await tspmIpcClient.request('restart', { id }); const resolved = await tspmIpcClient.request('resolveTarget', { target });
const response = await tspmIpcClient.request('restart', { id: resolved.id });
console.log(`✓ Process restarted successfully`); console.log(`✓ Process restarted successfully`);
console.log(` ID: ${response.processId}`); console.log(` ID: ${response.processId}${resolved.name ? ` (name: ${resolved.name})` : ''}`);
console.log(` PID: ${response.pid || 'N/A'}`); console.log(` PID: ${response.pid || 'N/A'}`);
console.log(` Status: ${response.status}`); console.log(` Status: ${response.status}`);
}, },

View File

@@ -0,0 +1,62 @@
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';
export function registerSearchCommand(smartcli: plugins.smartcli.Smartcli) {
registerIpcCommand(
smartcli,
'search',
async (argvArg: CliArguments) => {
const query = String(argvArg._[1] || '').trim();
if (!query) {
console.error('Error: Please provide a search query');
console.log('Usage: tspm search <name-fragment | id-fragment>');
return;
}
// Fetch list of processes, then enrich with names via describe
const listRes = await tspmIpcClient.request('list', {});
const processes = listRes.processes;
// If there are no processes, short-circuit
if (processes.length === 0) {
console.log('No processes found.');
return;
}
const lowerQ = query.toLowerCase();
const matches: Array<{ id: number; name?: string }> = [];
// Collect describe calls to obtain names
for (const proc of processes) {
try {
const desc = await tspmIpcClient.request('describe', { id: proc.id });
const name = desc.config.name || '';
const idStr = String(proc.id);
if (name.toLowerCase().includes(lowerQ) || idStr.includes(query)) {
matches.push({ id: proc.id, name });
}
} catch {
// Ignore describe errors for individual processes
}
}
if (matches.length === 0) {
console.log(`No matches for "${query}"`);
return;
}
console.log(`Matches for "${query}":`);
for (const m of matches) {
if (m.name) {
console.log(`- id:${m.id}\tname:${m.name}`);
} else {
console.log(`- id:${m.id}`);
}
}
},
{ actionLabel: 'search processes' },
);
}

View File

@@ -10,17 +10,18 @@ export function registerStartCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
'start', 'start',
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const id = argvArg._[1]; const target = argvArg._[1];
if (!id) { if (!target) {
console.error('Error: Please provide a process ID to start'); console.error('Error: Please provide a process target to start');
console.log('Usage: tspm start <id>'); console.log('Usage: tspm start <id | id:N | name:LABEL>');
return; return;
} }
console.log(`Starting process id ${id}...`); console.log(`Starting process: ${target}...`);
const response = await tspmIpcClient.request('startById', { id }); const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
const response = await tspmIpcClient.request('startById', { id: resolved.id });
console.log('✓ Process started'); console.log('✓ Process started');
console.log(` ID: ${response.processId}`); console.log(` ID: ${response.processId}${resolved.name ? ` (name: ${resolved.name})` : ''}`);
console.log(` PID: ${response.pid || 'N/A'}`); console.log(` PID: ${response.pid || 'N/A'}`);
console.log(` Status: ${response.status}`); console.log(` Status: ${response.status}`);
}, },

View File

@@ -8,15 +8,16 @@ export function registerStopCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
'stop', 'stop',
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const id = argvArg._[1]; const target = argvArg._[1];
if (!id) { if (!target) {
console.error('Error: Please provide a process ID'); console.error('Error: Please provide a process target');
console.log('Usage: tspm stop <id>'); console.log('Usage: tspm stop <id | id:N | name:LABEL>');
return; return;
} }
console.log(`Stopping process: ${id}`); console.log(`Stopping process: ${target}`);
const response = await tspmIpcClient.request('stop', { id }); const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
const response = await tspmIpcClient.request('stop', { id: resolved.id });
if (response.success) { if (response.success) {
console.log(`${response.message}`); console.log(`${response.message}`);

View File

@@ -2,6 +2,7 @@ import * as plugins from './plugins.js';
import { tspmIpcClient } from '../client/tspm.ipcclient.js'; import { tspmIpcClient } from '../client/tspm.ipcclient.js';
import * as paths from '../paths.js'; import * as paths from '../paths.js';
import { Logger, LogLevel } from '../shared/common/utils.errorhandler.js'; import { Logger, LogLevel } from '../shared/common/utils.errorhandler.js';
import { TspmServiceManager } from '../client/tspm.servicemanager.js';
// Import command registration functions // Import command registration functions
import { registerDefaultCommand } from './commands/default.js'; import { registerDefaultCommand } from './commands/default.js';
@@ -10,9 +11,11 @@ import { registerAddCommand } from './commands/process/add.js';
import { registerStopCommand } from './commands/process/stop.js'; import { registerStopCommand } from './commands/process/stop.js';
import { registerRestartCommand } from './commands/process/restart.js'; import { registerRestartCommand } from './commands/process/restart.js';
import { registerDeleteCommand } from './commands/process/delete.js'; import { registerDeleteCommand } from './commands/process/delete.js';
import { registerSearchCommand } from './commands/process/search.js';
import { registerListCommand } from './commands/process/list.js'; import { registerListCommand } from './commands/process/list.js';
import { registerDescribeCommand } from './commands/process/describe.js'; import { registerDescribeCommand } from './commands/process/describe.js';
import { registerLogsCommand } from './commands/process/logs.js'; import { registerLogsCommand } from './commands/process/logs.js';
import { registerEditCommand } from './commands/process/edit.js';
import { registerStartAllCommand } from './commands/batch/start-all.js'; import { registerStartAllCommand } from './commands/batch/start-all.js';
import { registerStopAllCommand } from './commands/batch/stop-all.js'; import { registerStopAllCommand } from './commands/batch/stop-all.js';
import { registerRestartAllCommand } from './commands/batch/restart-all.js'; import { registerRestartAllCommand } from './commands/batch/restart-all.js';
@@ -49,6 +52,38 @@ export const run = async (): Promise<void> => {
console.log( console.log(
`Daemon: running v${status.version || 'unknown'} (pid ${status.pid})`, `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 { } else {
console.log('Daemon: not running'); console.log('Daemon: not running');
} }
@@ -72,6 +107,8 @@ export const run = async (): Promise<void> => {
registerListCommand(smartcliInstance); registerListCommand(smartcliInstance);
registerDescribeCommand(smartcliInstance); registerDescribeCommand(smartcliInstance);
registerLogsCommand(smartcliInstance); registerLogsCommand(smartcliInstance);
registerEditCommand(smartcliInstance);
registerSearchCommand(smartcliInstance);
// Batch commands // Batch commands
registerStartAllCommand(smartcliInstance); registerStartAllCommand(smartcliInstance);

View File

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

View File

@@ -1,5 +1,7 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
import * as paths from '../paths.js'; import * as paths from '../paths.js';
import { toProcessId } from '../shared/protocol/id.js';
import type { ProcessId } from '../shared/protocol/id.js';
import type { import type {
IpcMethodMap, IpcMethodMap,
@@ -144,27 +146,81 @@ export class TspmIpcClient {
* Subscribe to log updates for a specific process * Subscribe to log updates for a specific process
*/ */
public async subscribe( public async subscribe(
processId: string, processId: ProcessId | number | string,
handler: (log: any) => void, handler: (log: any) => void,
): Promise<void> { ): Promise<void> {
if (!this.ipcClient || !this.isConnected) { if (!this.ipcClient || !this.isConnected) {
throw new Error('Not connected to daemon'); throw new Error('Not connected to daemon');
} }
const topic = `logs.${processId}`; const id = toProcessId(processId);
await this.ipcClient.subscribe(`topic:${topic}`, handler); const topic = `logs.${id}`;
// Note: IpcClient.subscribe expects the bare topic (without the 'topic:' prefix)
// and will register a handler for 'topic:<topic>' internally.
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 * Unsubscribe from log updates for a specific process
*/ */
public async unsubscribe(processId: string): Promise<void> { public async unsubscribe(processId: ProcessId | number | string): Promise<void> {
if (!this.ipcClient || !this.isConnected) { if (!this.ipcClient || !this.isConnected) {
throw new Error('Not connected to daemon'); throw new Error('Not connected to daemon');
} }
const topic = `logs.${processId}`; const id = toProcessId(processId);
await this.ipcClient.unsubscribe(`topic:${topic}`); const topic = `logs.${id}`;
// Pass bare topic; client handles 'topic:' prefix internally
await this.ipcClient.unsubscribe(topic);
} }
/** /**

View File

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

117
ts/daemon/logpersistence.ts Normal file
View File

@@ -0,0 +1,117 @@
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';
/**
* Manages persistent log storage for processes
*/
export class LogPersistence {
private logsDir: string;
constructor() {
this.logsDir = plugins.path.join(paths.tspmDir, 'logs');
}
/**
* Get the log file path for a process
*/
private getLogFilePath(processId: ProcessId): string {
return plugins.path.join(this.logsDir, `process-${processId}.json`);
}
/**
* Ensure the logs directory exists
*/
private async ensureLogsDir(): Promise<void> {
await plugins.smartfile.fs.ensureDir(this.logsDir);
}
/**
* Save logs to disk
*/
public async saveLogs(processId: ProcessId, logs: IProcessLog[]): Promise<void> {
await this.ensureLogsDir();
const filePath = this.getLogFilePath(processId);
// Write logs as JSON
await plugins.smartfile.memory.toFs(
JSON.stringify(logs, null, 2),
filePath
);
}
/**
* Load logs from disk
*/
public async loadLogs(processId: ProcessId): Promise<IProcessLog[]> {
const filePath = this.getLogFilePath(processId);
try {
const exists = await plugins.smartfile.fs.fileExists(filePath);
if (!exists) {
return [];
}
const content = await plugins.smartfile.fs.toStringSync(filePath);
const logs = JSON.parse(content) as IProcessLog[];
// Convert date strings back to Date objects
return logs.map(log => ({
...log,
timestamp: new Date(log.timestamp)
}));
} catch (error) {
console.error(`Failed to load logs for process ${processId}:`, error);
return [];
}
}
/**
* Delete logs from disk after loading
*/
public async deleteLogs(processId: ProcessId): Promise<void> {
const filePath = this.getLogFilePath(processId);
try {
const exists = await plugins.smartfile.fs.fileExists(filePath);
if (exists) {
await plugins.smartfile.fs.remove(filePath);
}
} catch (error) {
console.error(`Failed to delete logs for process ${processId}:`, error);
}
}
/**
* Calculate approximate memory size of logs in bytes
*/
public static calculateLogMemorySize(logs: IProcessLog[]): number {
// Estimate based on JSON string size
// This is an approximation but good enough for our purposes
return JSON.stringify(logs).length;
}
/**
* Clean up old log files (for maintenance)
*/
public async cleanupOldLogs(): Promise<void> {
try {
await this.ensureLogsDir();
const files = await plugins.smartfile.fs.listFileTree(this.logsDir, '*.json');
for (const file of files) {
const filePath = plugins.path.join(this.logsDir, file);
const stats = await plugins.smartfile.fs.stat(filePath);
// 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);
}
}
} catch (error) {
console.error('Failed to cleanup old logs:', error);
}
}
}

View File

@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import * as paths from '../paths.js'; import * as paths from '../paths.js';
import { ProcessMonitor } from './processmonitor.js'; import { ProcessMonitor } from './processmonitor.js';
import { LogPersistence } from './logpersistence.js';
import { TspmConfig } from './tspm.config.js'; import { TspmConfig } from './tspm.config.js';
import { import {
Logger, Logger,
@@ -16,17 +17,20 @@ import type {
IProcessLog, IProcessLog,
IMonitorConfig IMonitorConfig
} from '../shared/protocol/ipc.types.js'; } from '../shared/protocol/ipc.types.js';
import { toProcessId, getNextProcessId } from '../shared/protocol/id.js';
import type { ProcessId } from '../shared/protocol/id.js';
export class ProcessManager extends EventEmitter { export class ProcessManager extends EventEmitter {
public processes: Map<string, ProcessMonitor> = new Map(); public processes: Map<ProcessId, ProcessMonitor> = new Map();
public processConfigs: Map<string, IProcessConfig> = new Map(); public processConfigs: Map<ProcessId, IProcessConfig> = new Map();
public processInfo: Map<string, IProcessInfo> = new Map(); public processInfo: Map<ProcessId, IProcessInfo> = new Map();
private processLogs: Map<ProcessId, IProcessLog[]> = new Map();
private config: TspmConfig; private config: TspmConfig;
private configStorageKey = 'processes'; private configStorageKey = 'processes';
private desiredStateStorageKey = 'desiredStates'; private desiredStateStorageKey = 'desiredStates';
private desiredStates: Map<string, IProcessInfo['status']> = new Map(); private desiredStates: Map<ProcessId, IProcessInfo['status']> = new Map();
private logger: Logger; private logger: Logger;
constructor() { constructor() {
@@ -39,14 +43,14 @@ export class ProcessManager extends EventEmitter {
/** /**
* Add a process configuration without starting it. * Add a process configuration without starting it.
* Returns the assigned numeric sequential id as string. * Returns the assigned numeric sequential id.
*/ */
public async add(configInput: Omit<IProcessConfig, 'id'> & { id?: string }): Promise<string> { public async add(configInput: Omit<IProcessConfig, 'id'> & { id?: ProcessId }): Promise<ProcessId> {
// Determine next numeric id // Determine next numeric id
const nextId = this.getNextSequentialId(); const nextId = this.getNextSequentialId();
const config: IProcessConfig = { const config: IProcessConfig = {
id: String(nextId), id: nextId,
name: configInput.name || `process-${nextId}`, name: configInput.name || `process-${nextId}`,
command: configInput.command, command: configInput.command,
args: configInput.args, args: configInput.args,
@@ -111,7 +115,8 @@ export class ProcessManager extends EventEmitter {
// Create and start process monitor // Create and start process monitor
const monitor = new ProcessMonitor({ const monitor = new ProcessMonitor({
name: config.name || config.id, id: config.id, // Pass the ProcessId for log persistence
name: config.name || String(config.id),
projectDir: config.projectDir, projectDir: config.projectDir,
command: config.command, command: config.command,
args: config.args, args: config.args,
@@ -125,13 +130,48 @@ export class ProcessManager extends EventEmitter {
// Set up log event handler to re-emit for pub/sub // Set up log event handler to re-emit for pub/sub
monitor.on('log', (log: IProcessLog) => { monitor.on('log', (log: IProcessLog) => {
// Store log in our persistent storage
if (!this.processLogs.has(config.id)) {
this.processLogs.set(config.id, []);
}
const logs = this.processLogs.get(config.id)!;
logs.push(log);
// Trim logs if they exceed buffer size (default 1000)
const bufferSize = config.logBufferSize || 1000;
if (logs.length > bufferSize) {
this.processLogs.set(config.id, logs.slice(-bufferSize));
}
this.emit('process:log', { processId: config.id, log }); this.emit('process:log', { processId: config.id, log });
}); });
// Set up event handler to track PID when process starts
monitor.on('start', (pid: number) => {
this.updateProcessInfo(config.id, { pid });
});
// Set up event handler to clear PID when process exits
monitor.on('exit', () => {
this.updateProcessInfo(config.id, { pid: undefined });
});
monitor.start(); // Set up failure handler to mark process as errored
monitor.on('failed', () => {
this.updateProcessInfo(config.id, { status: 'errored', pid: undefined });
});
// Update process info await monitor.start();
this.updateProcessInfo(config.id, { status: 'online' });
// Wait a moment for the process to spawn and get its PID
await new Promise(resolve => setTimeout(resolve, 100));
// Update process info with PID
const pid = monitor.getPid();
this.updateProcessInfo(config.id, {
status: 'online',
pid: pid || undefined
});
// Save updated configs // Save updated configs
await this.saveProcessConfigs(); await this.saveProcessConfigs();
@@ -162,10 +202,36 @@ export class ProcessManager extends EventEmitter {
} }
} }
/**
* Update an existing process configuration
*/
public async update(
id: ProcessId,
updates: Partial<Omit<IProcessConfig, 'id'>>,
): Promise<IProcessConfig> {
const existing = this.processConfigs.get(id);
if (!existing) {
throw new ValidationError(
`Process with id '${id}' does not exist`,
'ERR_PROCESS_NOT_FOUND',
);
}
// Shallow merge; keep id intact
const merged: IProcessConfig = {
...existing,
...updates,
} as IProcessConfig;
this.processConfigs.set(id, merged);
await this.saveProcessConfigs();
return merged;
}
/** /**
* Stop a process by id * Stop a process by id
*/ */
public async stop(id: string): Promise<void> { public async stop(id: ProcessId): Promise<void> {
this.logger.info(`Stopping process with id '${id}'`); this.logger.info(`Stopping process with id '${id}'`);
const monitor = this.processes.get(id); const monitor = this.processes.get(id);
@@ -179,7 +245,7 @@ export class ProcessManager extends EventEmitter {
} }
try { try {
monitor.stop(); await monitor.stop();
this.updateProcessInfo(id, { status: 'stopped' }); this.updateProcessInfo(id, { status: 'stopped' });
this.logger.info(`Successfully stopped process with id '${id}'`); this.logger.info(`Successfully stopped process with id '${id}'`);
} catch (error: Error | unknown) { } catch (error: Error | unknown) {
@@ -199,7 +265,7 @@ export class ProcessManager extends EventEmitter {
/** /**
* Restart a process by id * Restart a process by id
*/ */
public async restart(id: string): Promise<void> { public async restart(id: ProcessId): Promise<void> {
this.logger.info(`Restarting process with id '${id}'`); this.logger.info(`Restarting process with id '${id}'`);
const monitor = this.processes.get(id); const monitor = this.processes.get(id);
@@ -216,11 +282,12 @@ export class ProcessManager extends EventEmitter {
try { try {
// Stop and then start the process // Stop and then start the process
monitor.stop(); await monitor.stop();
// Create a new monitor instance // Create a new monitor instance
const newMonitor = new ProcessMonitor({ const newMonitor = new ProcessMonitor({
name: config.name || config.id, id: config.id, // Pass the ProcessId for log persistence
name: config.name || String(config.id),
projectDir: config.projectDir, projectDir: config.projectDir,
command: config.command, command: config.command,
args: config.args, args: config.args,
@@ -230,18 +297,46 @@ export class ProcessManager extends EventEmitter {
logBufferSize: config.logBufferSize, logBufferSize: config.logBufferSize,
}); });
// Set up log event handler for the new monitor
newMonitor.on('log', (log: IProcessLog) => {
// Store log in our persistent storage
if (!this.processLogs.has(id)) {
this.processLogs.set(id, []);
}
const logs = this.processLogs.get(id)!;
logs.push(log);
// Trim logs if they exceed buffer size (default 1000)
const bufferSize = config.logBufferSize || 1000;
if (logs.length > bufferSize) {
this.processLogs.set(id, logs.slice(-bufferSize));
}
this.emit('process:log', { processId: id, log });
});
this.processes.set(id, newMonitor); this.processes.set(id, newMonitor);
newMonitor.start(); await newMonitor.start();
// Update restart count // Wait a moment for the process to spawn and get its PID
await new Promise(resolve => setTimeout(resolve, 100));
// Update restart count and PID
const info = this.processInfo.get(id); const info = this.processInfo.get(id);
if (info) { if (info) {
const pid = newMonitor.getPid();
this.updateProcessInfo(id, { this.updateProcessInfo(id, {
status: 'online', status: 'online',
pid: pid || undefined,
restarts: info.restarts + 1, restarts: info.restarts + 1,
}); });
} }
// Mark errored on failure events
newMonitor.on('failed', () => {
this.updateProcessInfo(id, { status: 'errored', pid: undefined });
});
this.logger.info(`Successfully restarted process with id '${id}'`); this.logger.info(`Successfully restarted process with id '${id}'`);
} catch (error: Error | unknown) { } catch (error: Error | unknown) {
const processError = new ProcessError( const processError = new ProcessError(
@@ -257,7 +352,7 @@ export class ProcessManager extends EventEmitter {
/** /**
* Delete a process by id * Delete a process by id
*/ */
public async delete(id: string): Promise<void> { public async delete(id: ProcessId): Promise<void> {
this.logger.info(`Deleting process with id '${id}'`); this.logger.info(`Deleting process with id '${id}'`);
// Check if process exists // Check if process exists
@@ -280,6 +375,11 @@ export class ProcessManager extends EventEmitter {
this.processes.delete(id); this.processes.delete(id);
this.processConfigs.delete(id); this.processConfigs.delete(id);
this.processInfo.delete(id); this.processInfo.delete(id);
this.processLogs.delete(id);
// Delete persisted logs from disk
const logPersistence = new LogPersistence();
await logPersistence.deleteLogs(id);
// Save updated configs // Save updated configs
await this.saveProcessConfigs(); await this.saveProcessConfigs();
@@ -292,6 +392,12 @@ export class ProcessManager extends EventEmitter {
this.processes.delete(id); this.processes.delete(id);
this.processConfigs.delete(id); this.processConfigs.delete(id);
this.processInfo.delete(id); this.processInfo.delete(id);
this.processLogs.delete(id);
// Delete persisted logs from disk even if stop failed
const logPersistence = new LogPersistence();
await logPersistence.deleteLogs(id);
await this.saveProcessConfigs(); await this.saveProcessConfigs();
await this.removeDesiredState(id); await this.removeDesiredState(id);
@@ -314,14 +420,49 @@ export class ProcessManager extends EventEmitter {
* Get a list of all process infos * Get a list of all process infos
*/ */
public list(): IProcessInfo[] { public list(): IProcessInfo[] {
return Array.from(this.processInfo.values()); const infos = Array.from(this.processInfo.values());
// Enrich with live data from monitors
for (const info of infos) {
const monitor = this.processes.get(info.id);
if (monitor) {
// Update with current PID if the monitor is running
const pid = monitor.getPid();
if (pid) {
info.pid = pid;
}
// Update uptime if available
const uptime = monitor.getUptime();
if (uptime !== null) {
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';
}
}
}
return infos;
} }
/** /**
* Get detailed info for a specific process * Get detailed info for a specific process
*/ */
public describe( public describe(
id: string, id: ProcessId,
): { config: IProcessConfig; info: IProcessInfo } | null { ): { config: IProcessConfig; info: IProcessInfo } | null {
const config = this.processConfigs.get(id); const config = this.processConfigs.get(id);
const info = this.processInfo.get(id); const info = this.processInfo.get(id);
@@ -336,13 +477,21 @@ export class ProcessManager extends EventEmitter {
/** /**
* Get process logs * Get process logs
*/ */
public getLogs(id: string, limit?: number): IProcessLog[] { public getLogs(id: ProcessId, limit?: number): IProcessLog[] {
// Get logs from the ProcessMonitor instance
const monitor = this.processes.get(id); const monitor = this.processes.get(id);
if (!monitor) {
return []; if (monitor) {
const logs = monitor.getLogs(limit);
return logs;
} }
return monitor.getLogs(limit); // Fallback to stored logs if monitor doesn't exist
const logs = this.processLogs.get(id) || [];
if (limit && limit > 0) {
return logs.slice(-limit);
}
return logs;
} }
/** /**
@@ -350,8 +499,12 @@ export class ProcessManager extends EventEmitter {
*/ */
public async startAll(): Promise<void> { public async startAll(): Promise<void> {
for (const [id, config] of this.processConfigs.entries()) { 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); 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);
} }
} }
} }
@@ -377,7 +530,7 @@ export class ProcessManager extends EventEmitter {
/** /**
* Update the info for a process * Update the info for a process
*/ */
private updateProcessInfo(id: string, update: Partial<IProcessInfo>): void { private updateProcessInfo(id: ProcessId, update: Partial<IProcessInfo>): void {
const info = this.processInfo.get(id); const info = this.processInfo.get(id);
if (info) { if (info) {
this.processInfo.set(id, { ...info, ...update }); this.processInfo.set(id, { ...info, ...update });
@@ -387,15 +540,40 @@ export class ProcessManager extends EventEmitter {
/** /**
* Compute next sequential numeric id based on existing configs * Compute next sequential numeric id based on existing configs
*/ */
private getNextSequentialId(): number { /**
let maxId = 0; * Sync process stats from monitors to processInfo
for (const id of this.processConfigs.keys()) { */
const n = parseInt(id, 10); public syncProcessStats(): void {
if (!isNaN(n)) { for (const [id, monitor] of this.processes.entries()) {
maxId = Math.max(maxId, n); const info = this.processInfo.get(id);
if (info) {
const pid = monitor.getPid();
const updates: Partial<IProcessInfo> = {};
// Update PID if available
if (pid) {
updates.pid = pid;
}
// Update uptime if available
const uptime = monitor.getUptime();
if (uptime !== null) {
updates.uptime = uptime;
}
// Update restart count
updates.restarts = monitor.getRestartCount();
// Update status based on actual running state
updates.status = monitor.isRunning() ? 'online' : 'stopped';
this.updateProcessInfo(id, updates);
} }
} }
return maxId + 1; }
private getNextSequentialId(): ProcessId {
return getNextProcessId(this.processConfigs.keys());
} }
/** /**
@@ -426,7 +604,7 @@ export class ProcessManager extends EventEmitter {
try { try {
const obj: Record<string, IProcessInfo['status']> = {}; const obj: Record<string, IProcessInfo['status']> = {};
for (const [id, state] of this.desiredStates.entries()) { for (const [id, state] of this.desiredStates.entries()) {
obj[id] = state; obj[String(id)] = state;
} }
await this.config.writeKey( await this.config.writeKey(
this.desiredStateStorageKey, this.desiredStateStorageKey,
@@ -444,7 +622,9 @@ export class ProcessManager extends EventEmitter {
const raw = await this.config.readKey(this.desiredStateStorageKey); const raw = await this.config.readKey(this.desiredStateStorageKey);
if (raw) { if (raw) {
const obj = JSON.parse(raw) as Record<string, IProcessInfo['status']>; const obj = JSON.parse(raw) as Record<string, IProcessInfo['status']>;
this.desiredStates = new Map(Object.entries(obj)); this.desiredStates = new Map(
Object.entries(obj).map(([k, v]) => [toProcessId(k), v] as const)
);
this.logger.debug( this.logger.debug(
`Loaded desired states for ${this.desiredStates.size} processes`, `Loaded desired states for ${this.desiredStates.size} processes`,
); );
@@ -457,14 +637,14 @@ export class ProcessManager extends EventEmitter {
} }
public async setDesiredState( public async setDesiredState(
id: string, id: ProcessId,
state: IProcessInfo['status'], state: IProcessInfo['status'],
): Promise<void> { ): Promise<void> {
this.desiredStates.set(id, state); this.desiredStates.set(id, state);
await this.saveDesiredStates(); await this.saveDesiredStates();
} }
public async removeDesiredState(id: string): Promise<void> { public async removeDesiredState(id: ProcessId): Promise<void> {
this.desiredStates.delete(id); this.desiredStates.delete(id);
await this.saveDesiredStates(); await this.saveDesiredStates();
} }
@@ -505,23 +685,35 @@ export class ProcessManager extends EventEmitter {
const configsJson = await this.config.readKey(this.configStorageKey); const configsJson = await this.config.readKey(this.configStorageKey);
if (configsJson) { if (configsJson) {
try { try {
const configs = JSON.parse(configsJson) as IProcessConfig[]; const parsed = JSON.parse(configsJson) as Array<any>;
this.logger.debug(`Loaded ${configs.length} process configurations`); this.logger.debug(`Loaded ${parsed.length} process configurations`);
for (const config of configs) { for (const raw of parsed) {
// Validate config // Convert legacy string IDs to ProcessId
if (!config.id || !config.command || !config.projectDir) { let id: ProcessId;
try {
id = toProcessId(raw.id);
} catch {
this.logger.warn( this.logger.warn(
`Skipping invalid process config for id '${config.id || 'unknown'}'`, `Skipping invalid process config with non-numeric id '${raw.id || 'unknown'}'`,
); );
continue; continue;
} }
this.processConfigs.set(config.id, config); // Validate config
if (!id || !raw.command || !raw.projectDir) {
this.logger.warn(
`Skipping invalid process config for id '${id || 'unknown'}'`,
);
continue;
}
const config: IProcessConfig = { ...raw, id };
this.processConfigs.set(id, config);
// Initialize process info // Initialize process info
this.processInfo.set(config.id, { this.processInfo.set(id, {
id: config.id, id: id,
status: 'stopped', status: 'stopped',
memory: 0, memory: 0,
restarts: 0, restarts: 0,
@@ -555,15 +747,15 @@ export class ProcessManager extends EventEmitter {
* Reset: stop all running processes and clear all saved configurations * Reset: stop all running processes and clear all saved configurations
*/ */
public async reset(): Promise<{ public async reset(): Promise<{
stopped: string[]; stopped: ProcessId[];
removed: string[]; removed: ProcessId[];
failed: Array<{ id: string; error: string }>; failed: Array<{ id: ProcessId; error: string }>;
}> { }> {
this.logger.info('Resetting TSPM: stopping all processes and clearing configs'); this.logger.info('Resetting TSPM: stopping all processes and clearing configs');
const removed = Array.from(this.processConfigs.keys()); const removed = Array.from(this.processConfigs.keys());
const stopped: string[] = []; const stopped: ProcessId[] = [];
const failed: Array<{ id: string; error: string }> = []; const failed: Array<{ id: ProcessId; error: string }> = [];
// Attempt to stop all currently running processes with per-id error collection // Attempt to stop all currently running processes with per-id error collection
for (const id of Array.from(this.processes.keys())) { for (const id of Array.from(this.processes.keys())) {

View File

@@ -1,8 +1,10 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { ProcessWrapper } from './processwrapper.js'; import { ProcessWrapper } from './processwrapper.js';
import { LogPersistence } from './logpersistence.js';
import { Logger, ProcessError, handleError } from '../shared/common/utils.errorhandler.js'; import { Logger, ProcessError, handleError } from '../shared/common/utils.errorhandler.js';
import type { IMonitorConfig, IProcessLog } from '../shared/protocol/ipc.types.js'; import type { IMonitorConfig, IProcessLog } from '../shared/protocol/ipc.types.js';
import type { ProcessId } from '../shared/protocol/id.js';
export class ProcessMonitor extends EventEmitter { export class ProcessMonitor extends EventEmitter {
private processWrapper: ProcessWrapper | null = null; private processWrapper: ProcessWrapper | null = null;
@@ -11,14 +13,50 @@ export class ProcessMonitor extends EventEmitter {
private stopped: boolean = true; // Initially stopped until start() is called private stopped: boolean = true; // Initially stopped until start() is called
private restartCount: number = 0; private restartCount: number = 0;
private logger: Logger; private logger: Logger;
private logs: IProcessLog[] = [];
private logPersistence: LogPersistence;
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;
constructor(config: IMonitorConfig) { constructor(config: IMonitorConfig & { id?: ProcessId }) {
super(); super();
this.config = config; this.config = config;
this.logger = new Logger(`ProcessMonitor:${config.name || 'unnamed'}`); this.logger = new Logger(`ProcessMonitor:${config.name || 'unnamed'}`);
this.logs = [];
this.logPersistence = new LogPersistence();
this.processId = config.id;
this.currentLogMemorySize = 0;
} }
public start(): void { public async start(): Promise<void> {
// Load previously persisted logs if available
if (this.processId) {
const persistedLogs = await this.logPersistence.loadLogs(this.processId);
if (persistedLogs.length > 0) {
this.logs = persistedLogs;
// 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
await this.logPersistence.deleteLogs(this.processId);
}
}
// Reset the stopped flag so that new processes can spawn. // Reset the stopped flag so that new processes can spawn.
this.stopped = false; this.stopped = false;
this.log(`Starting process monitor.`); this.log(`Starting process monitor.`);
@@ -57,6 +95,31 @@ export class ProcessMonitor extends EventEmitter {
// Set up event handlers // Set up event handlers
this.processWrapper.on('log', (log: IProcessLog): void => { this.processWrapper.on('log', (log: IProcessLog): void => {
// Store the log in our buffer
this.logs.push(log);
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 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
const removed = this.logs.shift()!;
const removedSize = this.logSizeMap.get(removed) ?? this.estimateLogSize(removed);
this.currentLogMemorySize -= removedSize;
}
// Re-emit the log event for upstream handlers // Re-emit the log event for upstream handlers
this.emit('log', log); this.emit('log', log);
@@ -65,19 +128,42 @@ export class ProcessMonitor extends EventEmitter {
this.log(log.message); this.log(log.message);
} }
}); });
// Re-emit start event with PID for upstream handlers
this.processWrapper.on('start', (pid: number): void => {
this.emit('start', pid);
});
this.processWrapper.on( this.processWrapper.on(
'exit', 'exit',
(code: number | null, signal: string | null): void => { async (code: number | null, signal: string | null): Promise<void> => {
const exitMsg = `Process exited with code ${code}, signal ${signal}.`; const exitMsg = `Process exited with code ${code}, signal ${signal}.`;
this.logger.info(exitMsg); this.logger.info(exitMsg);
this.log(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 {}
// Flush logs to disk on exit
if (this.processId && this.logs.length > 0) {
try {
await this.logPersistence.saveLogs(this.processId, this.logs);
this.logger.debug(`Flushed ${this.logs.length} logs to disk on exit`);
} catch (error) {
this.logger.error(`Failed to flush logs to disk on exit: ${error}`);
}
}
// Re-emit exit event for upstream handlers
this.emit('exit', code, signal);
if (!this.stopped) { if (!this.stopped) {
this.logger.info('Restarting process...'); this.scheduleRestart('exit');
this.log('Restarting process...');
this.restartCount++;
this.spawnProcess();
} else { } else {
this.logger.debug( this.logger.debug(
'Not restarting process because monitor is stopped', 'Not restarting process because monitor is stopped',
@@ -86,7 +172,7 @@ export class ProcessMonitor extends EventEmitter {
}, },
); );
this.processWrapper.on('error', (error: Error | ProcessError): void => { this.processWrapper.on('error', async (error: Error | ProcessError): Promise<void> => {
const errorMsg = const errorMsg =
error instanceof ProcessError error instanceof ProcessError
? `Process error: ${error.toString()}` ? `Process error: ${error.toString()}`
@@ -95,11 +181,18 @@ export class ProcessMonitor extends EventEmitter {
this.logger.error(error); this.logger.error(error);
this.log(errorMsg); this.log(errorMsg);
// Flush logs to disk on error
if (this.processId && this.logs.length > 0) {
try {
await this.logPersistence.saveLogs(this.processId, this.logs);
this.logger.debug(`Flushed ${this.logs.length} logs to disk on error`);
} catch (flushError) {
this.logger.error(`Failed to flush logs to disk on error: ${flushError}`);
}
}
if (!this.stopped) { if (!this.stopped) {
this.logger.info('Restarting process due to error...'); this.scheduleRestart('error');
this.log('Restarting process due to error...');
this.restartCount++;
this.spawnProcess();
} else { } else {
this.logger.debug('Not restarting process because monitor is stopped'); this.logger.debug('Not restarting process because monitor is stopped');
} }
@@ -117,6 +210,49 @@ export class ProcessMonitor extends EventEmitter {
} }
} }
/**
* Schedule a restart with incremental debounce and failure cutoff.
*/
private scheduleRestart(reason: 'exit' | 'error'): void {
const now = Date.now();
// Reset window: if last retry was more than 1 hour ago, reset counter
if (this.lastRetryAt && now - this.lastRetryAt >= this.RESET_WINDOW_MS) {
this.logger.info('Resetting retry counter after 1 hour window');
this.restartCount = 0;
}
// Already at or above max retries?
if (this.restartCount >= this.MAX_RETRIES) {
const msg = 'Maximum restart attempts reached. Marking process as failed.';
this.logger.warn(msg);
this.log(msg);
this.stopped = true;
// Emit a specific event so manager can set status to errored
this.emit('failed');
return;
}
// Increment and compute delay (1..10 seconds)
this.restartCount++;
const delaySec = Math.min(this.restartCount, 10);
const msg = `Restarting process in ${delaySec}s (attempt ${this.restartCount}/${this.MAX_RETRIES}) due to ${reason}...`;
this.logger.info(msg);
this.log(msg);
// Clear existing timer if any, then schedule
if (this.restartTimer) {
clearTimeout(this.restartTimer);
}
this.lastRetryAt = now;
this.restartTimer = setTimeout(() => {
// If stopped in the meantime, do not spawn
if (this.stopped) {
return;
}
this.spawnProcess();
}, delaySec * 1000);
}
/** /**
* Monitor the process group's memory usage. If the total memory exceeds the limit, * Monitor the process group's memory usage. If the total memory exceeds the limit,
* kill the process group so that the 'exit' handler can restart it. * kill the process group so that the 'exit' handler can restart it.
@@ -126,18 +262,24 @@ export class ProcessMonitor extends EventEmitter {
memoryLimit: number, memoryLimit: number,
): Promise<void> { ): Promise<void> {
try { try {
const memoryUsage = await this.getProcessGroupMemory(pid); const { memory: memoryUsage, cpu: cpuUsage } = await this.getProcessGroupStats(pid);
this.logger.debug( this.logger.debug(
`Memory usage for PID ${pid}: ${this.humanReadableBytes(memoryUsage)} (${memoryUsage} bytes)`, `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.log( this.lastMemoryUsage = memoryUsage;
`Current memory usage for process group (PID ${pid}): ${this.humanReadableBytes( this.lastCpuUsage = cpuUsage;
memoryUsage,
)} (${memoryUsage} bytes)`, // 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) { if (memoryUsage > memoryLimit) {
const memoryLimitMsg = `Memory usage ${this.humanReadableBytes( const memoryLimitMsg = `Memory usage ${this.humanReadableBytes(
@@ -149,7 +291,7 @@ export class ProcessMonitor extends EventEmitter {
// Stop the process wrapper, which will trigger the exit handler and restart // Stop the process wrapper, which will trigger the exit handler and restart
if (this.processWrapper) { if (this.processWrapper) {
this.processWrapper.stop(); await this.processWrapper.stop();
} }
} }
} catch (error: Error | unknown) { } catch (error: Error | unknown) {
@@ -167,7 +309,7 @@ export class ProcessMonitor extends EventEmitter {
/** /**
* Get the total memory usage (in bytes) for the process group (the main process and its children). * 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) => { return new Promise((resolve, reject) => {
this.logger.debug( this.logger.debug(
`Getting memory usage for process group with PID ${pid}`, `Getting memory usage for process group with PID ${pid}`,
@@ -175,7 +317,7 @@ export class ProcessMonitor extends EventEmitter {
plugins.psTree( plugins.psTree(
pid, pid,
(err: Error | null, children: Array<{ PID: string }>) => { (err: any, children: ReadonlyArray<{ PID: string }>) => {
if (err) { if (err) {
const processError = new ProcessError( const processError = new ProcessError(
`Failed to get process tree: ${err.message}`, `Failed to get process tree: ${err.message}`,
@@ -197,7 +339,7 @@ export class ProcessMonitor extends EventEmitter {
plugins.pidusage( plugins.pidusage(
pids, pids,
(err: Error | null, stats: Record<string, { memory: number }>) => { (err: Error | null, stats: Record<string, { memory: number; cpu: number }>) => {
if (err) { if (err) {
const processError = new ProcessError( const processError = new ProcessError(
`Failed to get process usage stats: ${err.message}`, `Failed to get process usage stats: ${err.message}`,
@@ -209,14 +351,16 @@ export class ProcessMonitor extends EventEmitter {
} }
let totalMemory = 0; let totalMemory = 0;
let totalCpu = 0;
for (const key in stats) { for (const key in stats) {
totalMemory += stats[key].memory; totalMemory += stats[key].memory;
totalCpu += Number.isFinite(stats[key].cpu) ? stats[key].cpu : 0;
} }
this.logger.debug( this.logger.debug(
`Total memory for process group: ${this.humanReadableBytes(totalMemory)}`, `Total memory for process group: ${this.humanReadableBytes(totalMemory)}`,
); );
resolve(totalMemory); resolve({ memory: totalMemory, cpu: totalCpu });
}, },
); );
}, },
@@ -239,14 +383,37 @@ export class ProcessMonitor extends EventEmitter {
/** /**
* Stop the monitor and prevent any further respawns. * Stop the monitor and prevent any further respawns.
*/ */
public stop(): void { public async stop(): Promise<void> {
this.log('Stopping process monitor.'); this.log('Stopping process monitor.');
this.stopped = true; this.stopped = true;
// Flush logs to disk before stopping
if (this.processId && this.logs.length > 0) {
try {
await this.logPersistence.saveLogs(this.processId, this.logs);
this.logger.info(`Flushed ${this.logs.length} logs to disk on stop`);
} catch (error) {
this.logger.error(`Failed to flush logs to disk on stop: ${error}`);
}
}
if (this.intervalId) { if (this.intervalId) {
clearInterval(this.intervalId); clearInterval(this.intervalId);
} }
// Cancel any pending restart timer
if (this.restartTimer) {
clearTimeout(this.restartTimer);
this.restartTimer = null;
}
if (this.processWrapper) { 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();
} }
} }
@@ -254,10 +421,16 @@ export class ProcessMonitor extends EventEmitter {
* Get the current logs from the process * Get the current logs from the process
*/ */
public getLogs(limit?: number): IProcessLog[] { public getLogs(limit?: number): IProcessLog[] {
if (!this.processWrapper) { if (process.env.TSPM_DEBUG) {
return []; console.error(
`[ProcessMonitor:${this.config.name}] getLogs called, logs.length=${this.logs.length}, limit=${limit}`,
);
} }
return this.processWrapper.getLogs(limit); this.logger.debug(`Getting logs, total stored: ${this.logs.length}`);
if (limit && limit > 0) {
return this.logs.slice(-limit);
}
return this.logs;
} }
/** /**
@@ -288,6 +461,20 @@ export class ProcessMonitor extends EventEmitter {
return this.processWrapper?.isRunning() || false; 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. * Helper method for logging messages with the instance name.
*/ */
@@ -295,4 +482,17 @@ export class ProcessMonitor extends EventEmitter {
const prefix = this.config.name ? `[${this.config.name}] ` : ''; const prefix = this.config.name ? `[${this.config.name}] ` : '';
console.log(prefix + message); 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

@@ -21,6 +21,28 @@ export class ProcessWrapper extends EventEmitter {
private logger: Logger; private logger: Logger;
private nextSeq: number = 0; private nextSeq: number = 0;
private runId: string = ''; private runId: string = '';
private stdoutRemainder: string = '';
private stderrRemainder: string = '';
// 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) { constructor(options: IProcessWrapperOptions) {
super(); super();
@@ -45,7 +67,7 @@ export class ProcessWrapper extends EventEmitter {
this.options.args, this.options.args,
{ {
cwd: this.options.cwd, cwd: this.options.cwd,
env: this.options.env || process.env, env: { ...process.env, ...(this.options.env || {}) },
stdio: ['ignore', 'pipe', 'pipe'], // We need to pipe stdout and stderr stdio: ['ignore', 'pipe', 'pipe'], // We need to pipe stdout and stderr
}, },
); );
@@ -53,7 +75,7 @@ export class ProcessWrapper extends EventEmitter {
// Use shell mode to allow a full command string // Use shell mode to allow a full command string
this.process = plugins.childProcess.spawn(this.options.command, { this.process = plugins.childProcess.spawn(this.options.command, {
cwd: this.options.cwd, cwd: this.options.cwd,
env: this.options.env || process.env, env: { ...process.env, ...(this.options.env || {}) },
stdio: ['ignore', 'pipe', 'pipe'], // We need to pipe stdout and stderr stdio: ['ignore', 'pipe', 'pipe'], // We need to pipe stdout and stderr
shell: true, shell: true,
}); });
@@ -66,6 +88,11 @@ export class ProcessWrapper extends EventEmitter {
const exitMessage = `Process exited with code ${code}, signal ${signal}`; const exitMessage = `Process exited with code ${code}, signal ${signal}`;
this.logger.info(exitMessage); this.logger.info(exitMessage);
this.addSystemLog(exitMessage); this.addSystemLog(exitMessage);
// Clear remainder buffers on exit
this.stdoutRemainder = '';
this.stderrRemainder = '';
this.emit('exit', code, signal); this.emit('exit', code, signal);
}); });
@@ -83,24 +110,69 @@ export class ProcessWrapper extends EventEmitter {
// Capture stdout // Capture stdout
if (this.process.stdout) { if (this.process.stdout) {
if (process.env.TSPM_DEBUG) {
console.error(
`[ProcessWrapper] Setting up stdout listener for process ${this.process.pid}`,
);
}
this.process.stdout.on('data', (data) => { this.process.stdout.on('data', (data) => {
const lines = data.toString().split('\n'); 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');
// The last element might be a partial line
this.stdoutRemainder = lines.pop() || '';
// Process complete lines
for (const line of lines) { for (const line of lines) {
if (line.trim()) { if (process.env.TSPM_DEBUG) {
this.addLog('stdout', line); console.error(`[ProcessWrapper] Processing stdout line: ${line}`);
} }
this.logger.debug(`Captured stdout: ${line}`);
this.addLog('stdout', line);
} }
}); });
// Flush remainder on stream end
this.process.stdout.on('end', () => {
if (this.stdoutRemainder) {
this.logger.debug(`Flushing stdout remainder: ${this.stdoutRemainder}`);
this.addLog('stdout', this.stdoutRemainder);
this.stdoutRemainder = '';
}
});
} else {
this.logger.warn('Process stdout is null');
} }
// Capture stderr // Capture stderr
if (this.process.stderr) { if (this.process.stderr) {
this.process.stderr.on('data', (data) => { this.process.stderr.on('data', (data) => {
const lines = data.toString().split('\n'); // Add data to remainder buffer and split by newlines
const text = this.stderrRemainder + data.toString();
const lines = text.split('\n');
// The last element might be a partial line
this.stderrRemainder = lines.pop() || '';
// Process complete lines
for (const line of lines) { for (const line of lines) {
if (line.trim()) { this.addLog('stderr', line);
this.addLog('stderr', line); }
} });
// Flush remainder on stream end
this.process.stderr.on('end', () => {
if (this.stderrRemainder) {
this.addLog('stderr', this.stderrRemainder);
this.stderrRemainder = '';
} }
}); });
} }
@@ -128,7 +200,7 @@ export class ProcessWrapper extends EventEmitter {
/** /**
* Stop the wrapped process * Stop the wrapped process
*/ */
public stop(): void { public async stop(): Promise<void> {
if (!this.process) { if (!this.process) {
this.logger.debug('Stop called but no process is running'); this.logger.debug('Stop called but no process is running');
this.addSystemLog('No process running'); this.addSystemLog('No process running');
@@ -141,30 +213,46 @@ export class ProcessWrapper extends EventEmitter {
// First try SIGTERM for graceful shutdown // First try SIGTERM for graceful shutdown
if (this.process.pid) { if (this.process.pid) {
try { try {
this.logger.debug(`Sending SIGTERM to process ${this.process.pid}`); this.logger.debug(`Sending SIGTERM to process tree rooted at ${this.process.pid}`);
process.kill(this.process.pid, 'SIGTERM'); await this.killProcessTree('SIGTERM');
// Give it 5 seconds to shut down gracefully // If the process already exited, return immediately
setTimeout((): void => { if (typeof this.process.exitCode === 'number') {
if (this.process && this.process.pid) { 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( this.logger.warn(
`Process ${this.process.pid} 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...',
); );
this.addSystemLog('Process did not exit gracefully, force killing...');
try { try {
process.kill(this.process.pid, 'SIGKILL'); await this.killProcessTree('SIGKILL');
} catch (error: Error | unknown) { } catch {}
// Process might have exited between checks // Give a short grace period after SIGKILL
this.logger.debug( setTimeout(() => cleanup(), 500);
`Failed to send SIGKILL, process probably already exited: ${ }, 5000);
error instanceof Error ? error.message : String(error)
}`, // Safety cap in case neither exit nor timer fires (shouldn't happen)
); setTimeout(() => {
} clearTimeout(killTimer);
} cleanup();
}, 5000); }, 10000);
});
} catch (error: Error | unknown) { } catch (error: Error | unknown) {
const processError = new ProcessError( const processError = new ProcessError(
error instanceof Error ? error.message : String(error), error instanceof Error ? error.message : String(error),

View File

@@ -1,5 +1,7 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import * as paths from '../paths.js'; import * as paths from '../paths.js';
import { toProcessId } from '../shared/protocol/id.js';
import type { ProcessId } from '../shared/protocol/id.js';
import { ProcessManager } from './processmanager.js'; import { ProcessManager } from './processmanager.js';
import type { import type {
IpcMethodMap, IpcMethodMap,
@@ -95,9 +97,25 @@ export class TspmDaemon {
this.tspmInstance.on('process:log', ({ processId, log }) => { this.tspmInstance.on('process:log', ({ processId, log }) => {
// Publish to topic for this process // Publish to topic for this process
const topic = `logs.${processId}`; const topic = `logs.${processId}`;
// Broadcast to all connected clients subscribed to this topic // Deliver only to subscribed clients
if (this.ipcServer) { 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);
}
} }
}); });
@@ -141,7 +159,7 @@ export class TspmDaemon {
'startById', 'startById',
async (request: RequestForMethod<'startById'>) => { async (request: RequestForMethod<'startById'>) => {
try { try {
const id = String(request.id).trim(); const id = toProcessId(request.id);
let config = this.tspmInstance.processConfigs.get(id); let config = this.tspmInstance.processConfigs.get(id);
if (!config) { if (!config) {
// Try to reload configs if not found (handles races or stale state) // Try to reload configs if not found (handles races or stale state)
@@ -169,7 +187,7 @@ export class TspmDaemon {
'stop', 'stop',
async (request: RequestForMethod<'stop'>) => { async (request: RequestForMethod<'stop'>) => {
try { try {
const id = String(request.id).trim(); const id = toProcessId(request.id);
await this.tspmInstance.setDesiredState(id, 'stopped'); await this.tspmInstance.setDesiredState(id, 'stopped');
await this.tspmInstance.stop(id); await this.tspmInstance.stop(id);
return { return {
@@ -186,7 +204,7 @@ export class TspmDaemon {
'restart', 'restart',
async (request: RequestForMethod<'restart'>) => { async (request: RequestForMethod<'restart'>) => {
try { try {
const id = String(request.id).trim(); const id = toProcessId(request.id);
await this.tspmInstance.setDesiredState(id, 'online'); await this.tspmInstance.setDesiredState(id, 'online');
await this.tspmInstance.restart(id); await this.tspmInstance.restart(id);
const processInfo = this.tspmInstance.processInfo.get(id); const processInfo = this.tspmInstance.processInfo.get(id);
@@ -205,7 +223,9 @@ export class TspmDaemon {
'delete', 'delete',
async (request: RequestForMethod<'delete'>) => { async (request: RequestForMethod<'delete'>) => {
try { try {
const id = String(request.id).trim(); const id = toProcessId(request.id);
// Ensure desired state reflects stopped before deletion
await this.tspmInstance.setDesiredState(id, 'stopped');
await this.tspmInstance.delete(id); await this.tspmInstance.delete(id);
return { return {
success: true, success: true,
@@ -232,18 +252,20 @@ export class TspmDaemon {
); );
this.ipcServer.onMessage( this.ipcServer.onMessage(
'remove', 'update',
async (request: RequestForMethod<'remove'>) => { async (request: RequestForMethod<'update'>) => {
try { try {
const id = String(request.id).trim(); const id = toProcessId(request.id);
await this.tspmInstance.delete(id); const updated = await this.tspmInstance.update(id, request.updates as any);
return { success: true, message: `Process ${id} deleted successfully` }; return { id, config: updated };
} catch (error) { } catch (error) {
throw new Error(`Failed to remove process: ${error.message}`); throw new Error(`Failed to update process: ${error.message}`);
} }
}, },
); );
// Note: 'remove' is only a CLI alias. Daemon exposes 'delete' only.
this.ipcServer.onMessage( this.ipcServer.onMessage(
'list', 'list',
async (request: RequestForMethod<'list'>) => { async (request: RequestForMethod<'list'>) => {
@@ -255,7 +277,7 @@ export class TspmDaemon {
this.ipcServer.onMessage( this.ipcServer.onMessage(
'describe', 'describe',
async (request: RequestForMethod<'describe'>) => { async (request: RequestForMethod<'describe'>) => {
const id = String(request.id).trim(); const id = toProcessId(request.id);
const result = await this.tspmInstance.describe(id); const result = await this.tspmInstance.describe(id);
if (!result) { if (!result) {
throw new Error(`Process ${id} not found`); throw new Error(`Process ${id} not found`);
@@ -271,17 +293,138 @@ export class TspmDaemon {
this.ipcServer.onMessage( this.ipcServer.onMessage(
'getLogs', 'getLogs',
async (request: RequestForMethod<'getLogs'>) => { async (request: RequestForMethod<'getLogs'>) => {
const logs = await this.tspmInstance.getLogs(request.id); const id = toProcessId(request.id);
const logs = await this.tspmInstance.getLogs(id, request.lines);
return { logs }; 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',
async (request: RequestForMethod<'resolveTarget'>) => {
const raw = String(request.target || '').trim();
if (!raw) {
throw new Error('Empty target');
}
// id:<n>
if (/^id:\s*\d+$/i.test(raw)) {
const idNum = raw.split(':')[1].trim();
const id = toProcessId(idNum);
const config = this.tspmInstance.processConfigs.get(id);
if (!config) throw new Error(`Process ${id} not found`);
return { id, name: config.name } as ResponseForMethod<'resolveTarget'>;
}
// name:<label>
if (/^name:/i.test(raw)) {
const name = raw.slice(raw.indexOf(':') + 1).trim();
if (!name) throw new Error('Missing name after name:');
const matches = Array.from(this.tspmInstance.processConfigs.values()).filter(
(c) => (c.name || '').trim() === name,
);
if (matches.length === 0) {
throw new Error(`No process found with name "${name}"`);
}
if (matches.length > 1) {
const ids = matches.map((c) => String(c.id)).join(', ');
throw new Error(
`Multiple processes found with name "${name}": ids [${ids}]. Please use id:<n>.`,
);
}
return { id: matches[0].id, name } as ResponseForMethod<'resolveTarget'>;
}
// bare numeric id
if (/^\d+$/.test(raw)) {
const id = toProcessId(raw);
const config = this.tspmInstance.processConfigs.get(id);
if (!config) throw new Error(`Process ${id} not found`);
return { id, name: config.name } as ResponseForMethod<'resolveTarget'>;
}
// Unknown format
throw new Error(
'Unsupported target format. Use numeric id (e.g. 1), id:<n> (e.g. id:1), or name:<label> (e.g. name:api).',
);
},
);
// Batch operations handlers // Batch operations handlers
this.ipcServer.onMessage( this.ipcServer.onMessage(
'startAll', 'startAll',
async (request: RequestForMethod<'startAll'>) => { async (request: RequestForMethod<'startAll'>) => {
const started: string[] = []; const started: ProcessId[] = [];
const failed: Array<{ id: string; error: string }> = []; const failed: Array<{ id: ProcessId; error: string }> = [];
await this.tspmInstance.setDesiredStateForAll('online'); await this.tspmInstance.setDesiredStateForAll('online');
await this.tspmInstance.startAll(); await this.tspmInstance.startAll();
@@ -302,15 +445,17 @@ export class TspmDaemon {
this.ipcServer.onMessage( this.ipcServer.onMessage(
'stopAll', 'stopAll',
async (request: RequestForMethod<'stopAll'>) => { async (request: RequestForMethod<'stopAll'>) => {
const stopped: string[] = []; const stopped: ProcessId[] = [];
const failed: Array<{ id: string; error: string }> = []; const failed: Array<{ id: ProcessId; error: string }> = [];
await this.tspmInstance.setDesiredStateForAll('stopped'); await this.tspmInstance.setDesiredStateForAll('stopped');
await this.tspmInstance.stopAll(); 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 // Determine which monitors are no longer running
for (const [id, processInfo] of this.tspmInstance.processInfo) { for (const [id, monitor] of this.tspmInstance.processes) {
if (processInfo.status === 'stopped') { if (!monitor.isRunning()) {
stopped.push(id); stopped.push(id);
} else { } else {
failed.push({ id, error: 'Failed to stop' }); failed.push({ id, error: 'Failed to stop' });
@@ -324,8 +469,8 @@ export class TspmDaemon {
this.ipcServer.onMessage( this.ipcServer.onMessage(
'restartAll', 'restartAll',
async (request: RequestForMethod<'restartAll'>) => { async (request: RequestForMethod<'restartAll'>) => {
const restarted: string[] = []; const restarted: ProcessId[] = [];
const failed: Array<{ id: string; error: string }> = []; const failed: Array<{ id: ProcessId; error: string }> = [];
await this.tspmInstance.restartAll(); await this.tspmInstance.restartAll();
@@ -556,3 +701,11 @@ export const startDaemon = async (): Promise<void> => {
// Keep the process alive // Keep the process alive
await new Promise(() => {}); await new Promise(() => {});
}; };
// If this file is run directly (not imported), start the daemon
if (process.env.TSPM_DAEMON_MODE === 'true') {
startDaemon().catch((error) => {
console.error('Failed to start TSPM daemon:', error);
process.exit(1);
});
}

View File

@@ -10,12 +10,13 @@ import * as npmextra from '@push.rocks/npmextra';
import * as projectinfo from '@push.rocks/projectinfo'; import * as projectinfo from '@push.rocks/projectinfo';
import * as smartcli from '@push.rocks/smartcli'; import * as smartcli from '@push.rocks/smartcli';
import * as smartdaemon from '@push.rocks/smartdaemon'; import * as smartdaemon from '@push.rocks/smartdaemon';
import * as smartfile from '@push.rocks/smartfile';
import * as smartipc from '@push.rocks/smartipc'; import * as smartipc from '@push.rocks/smartipc';
import * as smartpath from '@push.rocks/smartpath'; import * as smartpath from '@push.rocks/smartpath';
import * as smartinteract from '@push.rocks/smartinteract'; import * as smartinteract from '@push.rocks/smartinteract';
// Export with explicit module types // Export with explicit module types
export { npmextra, projectinfo, smartcli, smartdaemon, smartipc, smartpath, smartinteract }; export { npmextra, projectinfo, smartcli, smartdaemon, smartfile, smartipc, smartpath, smartinteract };
// third-party scope // third-party scope
import psTree from 'ps-tree'; import psTree from 'ps-tree';

56
ts/shared/protocol/id.ts Normal file
View File

@@ -0,0 +1,56 @@
/**
* Branded type for process IDs to ensure type safety
*/
export type ProcessId = number & { readonly __brand: 'ProcessId' };
/**
* Input type that accepts various ID formats for backward compatibility
*/
export type ProcessIdInput = ProcessId | number | string;
/**
* Normalizes various ID input formats to a ProcessId
* @param input - The ID in various formats (string, number, or ProcessId)
* @returns A normalized ProcessId
* @throws Error if the input is not a valid process ID
*/
export function toProcessId(input: ProcessIdInput): ProcessId {
let num: number;
if (typeof input === 'string') {
const trimmed = input.trim();
if (!/^\d+$/.test(trimmed)) {
throw new Error(`Invalid process ID: "${input}" is not a numeric string`);
}
num = parseInt(trimmed, 10);
} else if (typeof input === 'number') {
num = input;
} else {
// Already a ProcessId
return input;
}
if (!Number.isInteger(num) || num < 1) {
throw new Error(`Invalid process ID: ${input} must be a positive integer`);
}
return num as ProcessId;
}
/**
* Type guard to check if a value is a ProcessId
*/
export function isProcessId(value: unknown): value is ProcessId {
return typeof value === 'number' && Number.isInteger(value) && value >= 1;
}
/**
* Gets the next sequential ID given existing IDs
*/
export function getNextProcessId(existingIds: Iterable<ProcessId>): ProcessId {
let maxId = 0;
for (const id of existingIds) {
maxId = Math.max(maxId, id);
}
return (maxId + 1) as ProcessId;
}

View File

@@ -1,3 +1,5 @@
import type { ProcessId } from './id.js';
// Process-related interfaces (used in IPC communication) // Process-related interfaces (used in IPC communication)
export interface IMonitorConfig { export interface IMonitorConfig {
name?: string; // Optional name to identify the instance name?: string; // Optional name to identify the instance
@@ -11,14 +13,14 @@ export interface IMonitorConfig {
} }
export interface IProcessConfig extends IMonitorConfig { export interface IProcessConfig extends IMonitorConfig {
id: string; // Unique identifier for the process id: ProcessId; // Unique identifier for the process
autorestart: boolean; // Whether to restart the process automatically on crash autorestart: boolean; // Whether to restart the process automatically on crash
watch?: boolean; // Whether to watch for file changes and restart watch?: boolean; // Whether to watch for file changes and restart
watchPaths?: string[]; // Paths to watch for changes watchPaths?: string[]; // Paths to watch for changes
} }
export interface IProcessInfo { export interface IProcessInfo {
id: string; id: ProcessId;
pid?: number; pid?: number;
status: 'online' | 'stopped' | 'errored'; status: 'online' | 'stopped' | 'errored';
memory: number; memory: number;
@@ -61,25 +63,25 @@ export interface StartRequest {
} }
export interface StartResponse { export interface StartResponse {
processId: string; processId: ProcessId;
pid?: number; pid?: number;
status: 'online' | 'stopped' | 'errored'; status: 'online' | 'stopped' | 'errored';
} }
// Start by id (server resolves config) // Start by id (server resolves config)
export interface StartByIdRequest { export interface StartByIdRequest {
id: string; id: ProcessId;
} }
export interface StartByIdResponse { export interface StartByIdResponse {
processId: string; processId: ProcessId;
pid?: number; pid?: number;
status: 'online' | 'stopped' | 'errored'; status: 'online' | 'stopped' | 'errored';
} }
// Stop command // Stop command
export interface StopRequest { export interface StopRequest {
id: string; id: ProcessId;
} }
export interface StopResponse { export interface StopResponse {
@@ -89,18 +91,18 @@ export interface StopResponse {
// Restart command // Restart command
export interface RestartRequest { export interface RestartRequest {
id: string; id: ProcessId;
} }
export interface RestartResponse { export interface RestartResponse {
processId: string; processId: ProcessId;
pid?: number; pid?: number;
status: 'online' | 'stopped' | 'errored'; status: 'online' | 'stopped' | 'errored';
} }
// Delete command // Delete command
export interface DeleteRequest { export interface DeleteRequest {
id: string; id: ProcessId;
} }
export interface DeleteResponse { export interface DeleteResponse {
@@ -119,7 +121,7 @@ export interface ListResponse {
// Describe command // Describe command
export interface DescribeRequest { export interface DescribeRequest {
id: string; id: ProcessId;
} }
export interface DescribeResponse { export interface DescribeResponse {
@@ -129,7 +131,7 @@ export interface DescribeResponse {
// Get logs command // Get logs command
export interface GetLogsRequest { export interface GetLogsRequest {
id: string; id: ProcessId;
lines?: number; lines?: number;
} }
@@ -137,15 +139,38 @@ export interface GetLogsResponse {
logs: IProcessLog[]; 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 // Start all command
export interface StartAllRequest { export interface StartAllRequest {
// No parameters needed // No parameters needed
} }
export interface StartAllResponse { export interface StartAllResponse {
started: string[]; started: ProcessId[];
failed: Array<{ failed: Array<{
id: string; id: ProcessId;
error: string; error: string;
}>; }>;
} }
@@ -156,9 +181,9 @@ export interface StopAllRequest {
} }
export interface StopAllResponse { export interface StopAllResponse {
stopped: string[]; stopped: ProcessId[];
failed: Array<{ failed: Array<{
id: string; id: ProcessId;
error: string; error: string;
}>; }>;
} }
@@ -169,9 +194,9 @@ export interface RestartAllRequest {
} }
export interface RestartAllResponse { export interface RestartAllResponse {
restarted: string[]; restarted: ProcessId[];
failed: Array<{ failed: Array<{
id: string; id: ProcessId;
error: string; error: string;
}>; }>;
} }
@@ -182,10 +207,10 @@ export interface ResetRequest {
} }
export interface ResetResponse { export interface ResetResponse {
stopped: string[]; stopped: ProcessId[];
removed: string[]; removed: ProcessId[];
failed: Array<{ failed: Array<{
id: string; id: ProcessId;
error: string; error: string;
}>; }>;
} }
@@ -229,22 +254,35 @@ export interface HeartbeatResponse {
// Add (register config without starting) // Add (register config without starting)
export interface AddRequest { export interface AddRequest {
// Optional id is ignored server-side if present; server assigns sequential id // Optional id is ignored server-side if present; server assigns sequential id
config: Omit<IProcessConfig, 'id'> & { id?: string }; config: Omit<IProcessConfig, 'id'> & { id?: ProcessId };
} }
export interface AddResponse { export interface AddResponse {
id: string; id: ProcessId;
config: IProcessConfig; config: IProcessConfig;
} }
// Remove (delete config and stop if running) // Remove (delete config and stop if running)
export interface RemoveRequest {
id: string; // Update (modify existing config)
export interface UpdateRequest {
id: ProcessId;
updates: Partial<Omit<IProcessConfig, 'id'>>;
} }
export interface RemoveResponse { export interface UpdateResponse {
success: boolean; id: ProcessId;
message?: string; config: IProcessConfig;
}
// Resolve a user-provided target (id:n or name:foo or numeric string) to a ProcessId
export interface ResolveTargetRequest {
target: string;
}
export interface ResolveTargetResponse {
id: ProcessId;
name?: string;
} }
// Type mappings for methods // Type mappings for methods
@@ -255,10 +293,12 @@ export type IpcMethodMap = {
restart: { request: RestartRequest; response: RestartResponse }; restart: { request: RestartRequest; response: RestartResponse };
delete: { request: DeleteRequest; response: DeleteResponse }; delete: { request: DeleteRequest; response: DeleteResponse };
add: { request: AddRequest; response: AddResponse }; add: { request: AddRequest; response: AddResponse };
remove: { request: RemoveRequest; response: RemoveResponse }; update: { request: UpdateRequest; response: UpdateResponse };
list: { request: ListRequest; response: ListResponse }; list: { request: ListRequest; response: ListResponse };
describe: { request: DescribeRequest; response: DescribeResponse }; describe: { request: DescribeRequest; response: DescribeResponse };
getLogs: { request: GetLogsRequest; response: GetLogsResponse }; getLogs: { request: GetLogsRequest; response: GetLogsResponse };
'logs:subscribe': { request: LogsSubscribeRequest; response: LogsSubscribeResponse };
'logs:subscribers': { request: LogsSubscribersRequest; response: LogsSubscribersResponse };
startAll: { request: StartAllRequest; response: StartAllResponse }; startAll: { request: StartAllRequest; response: StartAllResponse };
stopAll: { request: StopAllRequest; response: StopAllResponse }; stopAll: { request: StopAllRequest; response: StopAllResponse };
restartAll: { request: RestartAllRequest; response: RestartAllResponse }; restartAll: { request: RestartAllRequest; response: RestartAllResponse };
@@ -272,6 +312,7 @@ export type IpcMethodMap = {
response: DaemonShutdownResponse; response: DaemonShutdownResponse;
}; };
heartbeat: { request: HeartbeatRequest; response: HeartbeatResponse }; heartbeat: { request: HeartbeatRequest; response: HeartbeatResponse };
resolveTarget: { request: ResolveTargetRequest; response: ResolveTargetResponse };
}; };
// Helper type to extract request type for a method // Helper type to extract request type for a method