Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
9473924fcc | |||
a0e7408c1a | |||
6e39b1db8f | |||
ee4532221a | |||
e39173a827 | |||
6f14033d9b | |||
1c4ffbb612 | |||
0a75c4cf76 |
37
changelog.md
37
changelog.md
@@ -1,5 +1,42 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-08-31 - 5.9.0 - feat(cli)
|
||||||
|
Add interactive edit flow to CLI and improve UX
|
||||||
|
|
||||||
|
- Add -i / --interactive flag to tspm add to open an interactive editor immediately after adding a process
|
||||||
|
- Implement interactiveEditProcess helper (smartinteract-based) to provide interactive editing for process configs
|
||||||
|
- Enable tspm edit to launch the interactive editor (replaces prior placeholder flow)
|
||||||
|
- Improve user-facing message when no processes are configured in tspm list
|
||||||
|
- Lower verbosity for missing saved configs on daemon startup (changed logger.info → logger.debug)
|
||||||
|
|
||||||
|
## 2025-08-31 - 5.8.0 - feat(core)
|
||||||
|
Add core TypeScript TSPM implementation: CLI, daemon, client, process management and tests
|
||||||
|
|
||||||
|
- Add CLI entrypoint and command set (start/stop/add/list/logs/daemon/service/stats/reset and batch ops)
|
||||||
|
- Add daemon implementation with ProcessManager, ProcessMonitor, ProcessWrapper, LogPersistence and config storage
|
||||||
|
- Add IPC client (tspmIpcClient) and TspmServiceManager for systemd integration using smartipc/smartdaemon
|
||||||
|
- Introduce shared protocol types, process ID helpers and standardized error codes for stable IPC
|
||||||
|
- Include tests and test assets for daemon, integration and IPC client scenarios
|
||||||
|
- Add README and package metadata (package.json, npmextra.json, commitinfo)
|
||||||
|
|
||||||
|
## 2025-08-31 - 5.7.0 - feat(cli)
|
||||||
|
Add 'stats' CLI command and daemon stats aggregation; fix process manager & wrapper state handling
|
||||||
|
|
||||||
|
- Add new 'stats' CLI command to show daemon + process statistics (memory, CPU, uptime, logs in memory, paths, configs) and include it in the default help output
|
||||||
|
- Implement daemon-side aggregation for logs-in-memory, per-process log counts/bytes, and expose tspmDir/socket/pidFile and config counts in daemon:status
|
||||||
|
- Enhance startById handler to detect already-running monitors and return current status/pid instead of attempting to restart
|
||||||
|
- Improve ProcessManager start/restart/stop behavior: if an existing monitor exists but is not running, restart it; ensure PID and status are updated consistently (clear PID on stop)
|
||||||
|
- Fix ProcessWrapper lifecycle handling: clear internal process reference on exit, improve isRunning() and getPid() semantics to reflect actual runtime state
|
||||||
|
- Update IPC types to include optional metadata fields (paths, configs, logsInMemory) in DaemonStatusResponse
|
||||||
|
|
||||||
|
## 2025-08-31 - 5.6.2 - fix(processmanager)
|
||||||
|
Improve process lifecycle handling and cleanup in daemon, monitors and wrappers
|
||||||
|
|
||||||
|
- StartAll: when a monitor exists but is not running, restart it instead of skipping — ensures saved processes are reliably brought online.
|
||||||
|
- ProcessMonitor.stop: cancel any pending restart timers to prevent stray restarts after explicit stop.
|
||||||
|
- ProcessWrapper: add killProcessTree helper and use it for graceful (SIGTERM) and force (SIGKILL) shutdowns to reliably signal child processes.
|
||||||
|
- Daemon stopAll: yield briefly after stopping processes and inspect monitors (not only processInfo) to accurately report stopped vs failed processes.
|
||||||
|
|
||||||
## 2025-08-31 - 5.6.1 - fix(daemon)
|
## 2025-08-31 - 5.6.1 - fix(daemon)
|
||||||
Ensure robust process shutdown and improve logs/subscriber diagnostics
|
Ensure robust process shutdown and improve logs/subscriber diagnostics
|
||||||
|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@git.zone/tspm",
|
"name": "@git.zone/tspm",
|
||||||
"version": "5.6.1",
|
"version": "5.9.0",
|
||||||
"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",
|
||||||
|
@@ -72,6 +72,7 @@ Add a new process configuration without starting it. This is the recommended way
|
|||||||
- `--watch` - Enable file watching for auto-restart
|
- `--watch` - Enable file watching for auto-restart
|
||||||
- `--watch-paths <paths>` - Comma-separated paths to watch
|
- `--watch-paths <paths>` - Comma-separated paths to watch
|
||||||
- `--autorestart` - Auto-restart on crash (default: true)
|
- `--autorestart` - Auto-restart on crash (default: true)
|
||||||
|
- `-i, --interactive` - Enter interactive edit mode after adding
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
```bash
|
```bash
|
||||||
@@ -86,6 +87,9 @@ tspm add "tsx watch src/index.ts" --name dev-server --watch --watch-paths "src,c
|
|||||||
|
|
||||||
# Add without auto-restart
|
# Add without auto-restart
|
||||||
tspm add "node worker.js" --name one-time-job --autorestart false
|
tspm add "node worker.js" --name one-time-job --autorestart false
|
||||||
|
|
||||||
|
# Add and immediately edit interactively
|
||||||
|
tspm add "node server.js" --name api -i
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `tspm start <id|id:N|name:LABEL>`
|
#### `tspm start <id|id:N|name:LABEL>`
|
||||||
|
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@git.zone/tspm',
|
name: '@git.zone/tspm',
|
||||||
version: '5.6.1',
|
version: '5.9.0',
|
||||||
description: 'a no fuzz process manager'
|
description: 'a no fuzz process manager'
|
||||||
}
|
}
|
||||||
|
@@ -39,6 +39,7 @@ export function registerDefaultCommand(smartcli: plugins.smartcli.Smartcli) {
|
|||||||
);
|
);
|
||||||
console.log(' daemon stop Stop the daemon');
|
console.log(' daemon stop Stop the daemon');
|
||||||
console.log(' daemon status Show daemon status');
|
console.log(' daemon status Show daemon status');
|
||||||
|
console.log(' stats Show daemon + process stats');
|
||||||
console.log(
|
console.log(
|
||||||
'\nUse tspm [command] --help for more information about a command.',
|
'\nUse tspm [command] --help for more information about a command.',
|
||||||
);
|
);
|
||||||
|
@@ -20,6 +20,7 @@ export function registerAddCommand(smartcli: plugins.smartcli.Smartcli) {
|
|||||||
console.log(' --watch Watch for file changes');
|
console.log(' --watch Watch for file changes');
|
||||||
console.log(' --watch-paths <paths> Comma-separated paths');
|
console.log(' --watch-paths <paths> Comma-separated paths');
|
||||||
console.log(' --autorestart Auto-restart on crash (default true)');
|
console.log(' --autorestart Auto-restart on crash (default true)');
|
||||||
|
console.log(' -i, --interactive Enter interactive edit mode after adding');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +30,9 @@ export function registerAddCommand(smartcli: plugins.smartcli.Smartcli) {
|
|||||||
? parseMemoryString(argvArg.memory)
|
? parseMemoryString(argvArg.memory)
|
||||||
: 512 * 1024 * 1024;
|
: 512 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Check for interactive flag
|
||||||
|
const isInteractive = argvArg.i || argvArg.interactive;
|
||||||
|
|
||||||
// Resolve .ts single-file execution via tsx if needed
|
// Resolve .ts single-file execution via tsx if needed
|
||||||
const parts = script.split(' ');
|
const parts = script.split(' ');
|
||||||
const first = parts[0];
|
const first = parts[0];
|
||||||
@@ -112,6 +116,12 @@ export function registerAddCommand(smartcli: plugins.smartcli.Smartcli) {
|
|||||||
|
|
||||||
console.log('✓ Added');
|
console.log('✓ Added');
|
||||||
console.log(` Assigned ID: ${response.id}`);
|
console.log(` Assigned ID: ${response.id}`);
|
||||||
|
|
||||||
|
// If interactive flag is set, enter edit mode
|
||||||
|
if (isInteractive) {
|
||||||
|
const { interactiveEditProcess } = await import('../../helpers/interactive-edit.js');
|
||||||
|
await interactiveEditProcess(response.id);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ actionLabel: 'add process config' },
|
{ actionLabel: 'add process config' },
|
||||||
);
|
);
|
||||||
|
@@ -16,58 +16,12 @@ export function registerEditCommand(smartcli: plugins.smartcli.Smartcli) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve and load current config
|
// Resolve the target to get the process ID
|
||||||
const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
|
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
|
// Use the shared interactive edit function
|
||||||
console.log('Interactive editing is temporarily disabled.');
|
const { interactiveEditProcess } = await import('../../helpers/interactive-edit.js');
|
||||||
console.log('Current configuration:');
|
await interactiveEditProcess(resolved.id);
|
||||||
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' },
|
{ actionLabel: 'edit process config' },
|
||||||
);
|
);
|
||||||
|
@@ -14,7 +14,9 @@ export function registerListCommand(smartcli: plugins.smartcli.Smartcli) {
|
|||||||
const processes = response.processes;
|
const processes = response.processes;
|
||||||
|
|
||||||
if (processes.length === 0) {
|
if (processes.length === 0) {
|
||||||
console.log('No processes running.');
|
console.log('No processes configured.');
|
||||||
|
console.log('Use "tspm add <command>" to add one, e.g.:');
|
||||||
|
console.log(' tspm add "pnpm start"');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
66
ts/cli/commands/stats.ts
Normal file
66
ts/cli/commands/stats.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { tspmIpcClient } from '../../client/tspm.ipcclient.js';
|
||||||
|
import type { CliArguments } from '../types.js';
|
||||||
|
import { registerIpcCommand } from '../registration/index.js';
|
||||||
|
import { pad } from '../helpers/formatting.js';
|
||||||
|
import { formatMemory } from '../helpers/memory.js';
|
||||||
|
|
||||||
|
export function registerStatsCommand(smartcli: plugins.smartcli.Smartcli) {
|
||||||
|
registerIpcCommand(
|
||||||
|
smartcli,
|
||||||
|
'stats',
|
||||||
|
async (_argvArg: CliArguments) => {
|
||||||
|
// Daemon status
|
||||||
|
const status = await tspmIpcClient.request('daemon:status', {});
|
||||||
|
|
||||||
|
console.log('TSPM Daemon:');
|
||||||
|
console.log('─'.repeat(60));
|
||||||
|
console.log(`Version: ${status.version || 'unknown'}`);
|
||||||
|
console.log(`PID: ${status.pid}`);
|
||||||
|
console.log(`Uptime: ${Math.floor((status.uptime || 0) / 1000)}s`);
|
||||||
|
console.log(`Processes: ${status.processCount}`);
|
||||||
|
if (typeof status.memoryUsage === 'number') {
|
||||||
|
console.log(`Memory: ${formatMemory(status.memoryUsage)}`);
|
||||||
|
}
|
||||||
|
if (typeof status.cpuUsage === 'number') {
|
||||||
|
console.log(`CPU (user): ${status.cpuUsage.toFixed(3)}s`);
|
||||||
|
}
|
||||||
|
if ((status as any).paths) {
|
||||||
|
const pathsInfo = (status as any).paths as { tspmDir?: string; socketPath?: string; pidFile?: string };
|
||||||
|
console.log(`tspmDir: ${pathsInfo.tspmDir || '-'}`);
|
||||||
|
console.log(`Socket: ${pathsInfo.socketPath || '-'}`);
|
||||||
|
console.log(`PID File: ${pathsInfo.pidFile || '-'}`);
|
||||||
|
}
|
||||||
|
if ((status as any).configs) {
|
||||||
|
const cfg = (status as any).configs as { processConfigs?: number };
|
||||||
|
console.log(`Configs: ${cfg.processConfigs ?? 0}`);
|
||||||
|
}
|
||||||
|
if ((status as any).logsInMemory) {
|
||||||
|
const lm = (status as any).logsInMemory as { totalCount: number; totalBytes: number };
|
||||||
|
console.log(`Logs (mem): ${lm.totalCount} entries, ${formatMemory(lm.totalBytes)}`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Process list (reuse list view with CPU column)
|
||||||
|
const response = await tspmIpcClient.request('list', {});
|
||||||
|
const processes = response.processes;
|
||||||
|
console.log('Process List:');
|
||||||
|
console.log('┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────┬─────────┐');
|
||||||
|
console.log('│ ID │ Name │ Status │ PID │ Memory │ CPU │ Restarts │');
|
||||||
|
console.log('├─────────┼─────────────┼───────────┼───────────┼──────────┼──────────┼──────────┤');
|
||||||
|
for (const proc of processes) {
|
||||||
|
const statusColor =
|
||||||
|
proc.status === 'online' ? '\x1b[32m' : proc.status === 'errored' ? '\x1b[31m' : '\x1b[33m';
|
||||||
|
const resetColor = '\x1b[0m';
|
||||||
|
const cpuStr = typeof proc.cpu === 'number' && isFinite(proc.cpu) ? `${proc.cpu.toFixed(1)}%` : '-';
|
||||||
|
const nameDisplay = String(proc.id); // name not carried in IProcessInfo
|
||||||
|
console.log(
|
||||||
|
`│ ${pad(String(proc.id), 7)} │ ${pad(nameDisplay, 11)} │ ${statusColor}${pad(proc.status, 9)}${resetColor} │ ${pad((proc.pid || '-').toString(), 9)} │ ${pad(formatMemory(proc.memory), 8)} │ ${pad(cpuStr, 8)} │ ${pad(proc.restarts.toString(), 8)} │`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┴──────────┘');
|
||||||
|
},
|
||||||
|
{ actionLabel: 'get daemon stats' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
164
ts/cli/helpers/interactive-edit.ts
Normal file
164
ts/cli/helpers/interactive-edit.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { tspmIpcClient } from '../../client/tspm.ipcclient.js';
|
||||||
|
import { formatMemory, parseMemoryString } from './memory.js';
|
||||||
|
|
||||||
|
export async function interactiveEditProcess(processId: number): Promise<void> {
|
||||||
|
// Load current config
|
||||||
|
const { config } = await tspmIpcClient.request('describe', { id: processId as any });
|
||||||
|
|
||||||
|
// Create interactive prompts for editing
|
||||||
|
const smartInteract = new plugins.smartinteract.SmartInteract([
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'input',
|
||||||
|
message: 'Process name:',
|
||||||
|
default: config.name,
|
||||||
|
validate: (input: string) => {
|
||||||
|
return input && input.trim() !== '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'command',
|
||||||
|
type: 'input',
|
||||||
|
message: 'Command to execute:',
|
||||||
|
default: config.command,
|
||||||
|
validate: (input: string) => {
|
||||||
|
return input && input.trim() !== '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'projectDir',
|
||||||
|
type: 'input',
|
||||||
|
message: 'Working directory:',
|
||||||
|
default: config.projectDir,
|
||||||
|
validate: (input: string) => {
|
||||||
|
return input && input.trim() !== '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'memoryLimit',
|
||||||
|
type: 'input',
|
||||||
|
message: 'Memory limit (e.g., 512M, 1G):',
|
||||||
|
default: formatMemory(config.memoryLimitBytes),
|
||||||
|
validate: (input: string) => {
|
||||||
|
const parsed = parseMemoryString(input);
|
||||||
|
return parsed !== null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'autorestart',
|
||||||
|
type: 'confirm',
|
||||||
|
message: 'Enable auto-restart on failure?',
|
||||||
|
default: config.autorestart
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'watch',
|
||||||
|
type: 'confirm',
|
||||||
|
message: 'Enable file watching for auto-restart?',
|
||||||
|
default: config.watch || false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updateEnv',
|
||||||
|
type: 'confirm',
|
||||||
|
message: 'Update environment variables to current environment?',
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('\n📝 Edit Process Configuration');
|
||||||
|
console.log(` Process ID: ${processId}`);
|
||||||
|
console.log(' (Press Enter to keep current values)\n');
|
||||||
|
|
||||||
|
// Run the interactive prompts
|
||||||
|
const answerBucket = await smartInteract.runQueue();
|
||||||
|
|
||||||
|
// Get answers from the bucket
|
||||||
|
const name = answerBucket.getAnswerFor('name');
|
||||||
|
const command = answerBucket.getAnswerFor('command');
|
||||||
|
const projectDir = answerBucket.getAnswerFor('projectDir');
|
||||||
|
const memoryLimit = answerBucket.getAnswerFor('memoryLimit');
|
||||||
|
const autorestart = answerBucket.getAnswerFor('autorestart');
|
||||||
|
const watch = answerBucket.getAnswerFor('watch');
|
||||||
|
const updateEnv = answerBucket.getAnswerFor('updateEnv');
|
||||||
|
|
||||||
|
// Prepare updates object
|
||||||
|
const updates: any = {};
|
||||||
|
|
||||||
|
// Check what has changed
|
||||||
|
if (name !== config.name) {
|
||||||
|
updates.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command !== config.command) {
|
||||||
|
updates.command = command;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectDir !== config.projectDir) {
|
||||||
|
updates.projectDir = projectDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMemoryBytes = parseMemoryString(memoryLimit);
|
||||||
|
if (newMemoryBytes !== config.memoryLimitBytes) {
|
||||||
|
updates.memoryLimitBytes = newMemoryBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autorestart !== config.autorestart) {
|
||||||
|
updates.autorestart = autorestart;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (watch !== config.watch) {
|
||||||
|
updates.watch = watch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle environment variables update if requested
|
||||||
|
if (updateEnv) {
|
||||||
|
const essentialEnvVars: NodeJS.ProcessEnv = {
|
||||||
|
PATH: process.env.PATH || '',
|
||||||
|
HOME: process.env.HOME,
|
||||||
|
USER: process.env.USER,
|
||||||
|
SHELL: process.env.SHELL,
|
||||||
|
LANG: process.env.LANG,
|
||||||
|
LC_ALL: process.env.LC_ALL,
|
||||||
|
// Node.js specific
|
||||||
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
|
NODE_PATH: process.env.NODE_PATH,
|
||||||
|
// npm/pnpm/yarn paths
|
||||||
|
npm_config_prefix: process.env.npm_config_prefix,
|
||||||
|
// Include any TSPM_ prefixed vars
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(process.env).filter(([key]) => key.startsWith('TSPM_'))
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove undefined values
|
||||||
|
Object.keys(essentialEnvVars).forEach(key => {
|
||||||
|
if (essentialEnvVars[key] === undefined) {
|
||||||
|
delete essentialEnvVars[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updates.env = { ...(config.env || {}), ...essentialEnvVars };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update if there are changes
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
console.log('\n✓ No changes made');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send updates to daemon
|
||||||
|
await tspmIpcClient.request('update', {
|
||||||
|
id: processId as any,
|
||||||
|
updates,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Display what was updated
|
||||||
|
console.log('\n✓ Process configuration updated successfully');
|
||||||
|
if (updates.name) console.log(` Name: ${updates.name}`);
|
||||||
|
if (updates.command) console.log(` Command: ${updates.command}`);
|
||||||
|
if (updates.projectDir) console.log(` Directory: ${updates.projectDir}`);
|
||||||
|
if (updates.memoryLimitBytes) console.log(` Memory limit: ${formatMemory(updates.memoryLimitBytes)}`);
|
||||||
|
if (updates.autorestart !== undefined) console.log(` Auto-restart: ${updates.autorestart}`);
|
||||||
|
if (updates.watch !== undefined) console.log(` Watch: ${updates.watch ? 'enabled' : 'disabled'}`);
|
||||||
|
if (updateEnv) console.log(' Environment variables: updated');
|
||||||
|
}
|
@@ -20,6 +20,7 @@ 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';
|
||||||
import { registerDaemonCommand } from './commands/daemon/index.js';
|
import { registerDaemonCommand } from './commands/daemon/index.js';
|
||||||
|
import { registerStatsCommand } from './commands/stats.js';
|
||||||
import { registerEnableCommand } from './commands/service/enable.js';
|
import { registerEnableCommand } from './commands/service/enable.js';
|
||||||
import { registerDisableCommand } from './commands/service/disable.js';
|
import { registerDisableCommand } from './commands/service/disable.js';
|
||||||
import { registerResetCommand } from './commands/reset.js';
|
import { registerResetCommand } from './commands/reset.js';
|
||||||
@@ -117,6 +118,7 @@ export const run = async (): Promise<void> => {
|
|||||||
|
|
||||||
// Daemon commands
|
// Daemon commands
|
||||||
registerDaemonCommand(smartcliInstance);
|
registerDaemonCommand(smartcliInstance);
|
||||||
|
registerStatsCommand(smartcliInstance);
|
||||||
|
|
||||||
// Service commands
|
// Service commands
|
||||||
registerEnableCommand(smartcliInstance);
|
registerEnableCommand(smartcliInstance);
|
||||||
|
@@ -95,6 +95,16 @@ export class ProcessManager extends EventEmitter {
|
|||||||
|
|
||||||
// Check if process with this id already exists
|
// Check if process with this id already exists
|
||||||
if (this.processes.has(config.id)) {
|
if (this.processes.has(config.id)) {
|
||||||
|
const existing = this.processes.get(config.id)!;
|
||||||
|
// If an existing monitor is present but not running, treat this as a fresh start via restart logic
|
||||||
|
if (!existing.isRunning()) {
|
||||||
|
this.logger.info(
|
||||||
|
`Existing monitor found for id '${config.id}' but not running. Restarting it...`,
|
||||||
|
);
|
||||||
|
await this.restart(config.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Already running – surface a meaningful error
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
`Process with id '${config.id}' already exists`,
|
`Process with id '${config.id}' already exists`,
|
||||||
'ERR_DUPLICATE_PROCESS',
|
'ERR_DUPLICATE_PROCESS',
|
||||||
@@ -246,7 +256,8 @@ export class ProcessManager extends EventEmitter {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await monitor.stop();
|
await monitor.stop();
|
||||||
this.updateProcessInfo(id, { status: 'stopped' });
|
// Ensure status and PID are reflected immediately
|
||||||
|
this.updateProcessInfo(id, { status: 'stopped', pid: undefined });
|
||||||
this.logger.info(`Successfully stopped process with id '${id}'`);
|
this.logger.info(`Successfully stopped process with id '${id}'`);
|
||||||
} catch (error: Error | unknown) {
|
} catch (error: Error | unknown) {
|
||||||
const processError = new ProcessError(
|
const processError = new ProcessError(
|
||||||
@@ -430,6 +441,8 @@ export class ProcessManager extends EventEmitter {
|
|||||||
const pid = monitor.getPid();
|
const pid = monitor.getPid();
|
||||||
if (pid) {
|
if (pid) {
|
||||||
info.pid = pid;
|
info.pid = pid;
|
||||||
|
} else {
|
||||||
|
info.pid = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update uptime if available
|
// Update uptime if available
|
||||||
@@ -449,9 +462,7 @@ export class ProcessManager extends EventEmitter {
|
|||||||
info.restarts = monitor.getRestartCount();
|
info.restarts = monitor.getRestartCount();
|
||||||
|
|
||||||
// Update status based on actual running state
|
// Update status based on actual running state
|
||||||
if (monitor.isRunning()) {
|
info.status = monitor.isRunning() ? 'online' : 'stopped';
|
||||||
info.status = 'online';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,8 +510,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -724,7 +739,8 @@ export class ProcessManager extends EventEmitter {
|
|||||||
throw configError;
|
throw configError;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.info('No saved process configurations found');
|
// First run / no configs yet — keep this quiet unless debugging
|
||||||
|
this.logger.debug('No saved process configurations found');
|
||||||
}
|
}
|
||||||
} catch (error: Error | unknown) {
|
} catch (error: Error | unknown) {
|
||||||
// Only throw if it's not the "no configs found" case
|
// Only throw if it's not the "no configs found" case
|
||||||
@@ -733,9 +749,7 @@ export class ProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If no configs found or error reading, just continue with empty configs
|
// If no configs found or error reading, just continue with empty configs
|
||||||
this.logger.info(
|
this.logger.debug('No saved process configurations found or error reading them');
|
||||||
'No saved process configurations found or error reading them',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -400,6 +400,11 @@ export class ProcessMonitor extends EventEmitter {
|
|||||||
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) {
|
||||||
// Clear pidusage state for current PID before stopping to avoid leaks
|
// Clear pidusage state for current PID before stopping to avoid leaks
|
||||||
try {
|
try {
|
||||||
|
@@ -24,6 +24,26 @@ export class ProcessWrapper extends EventEmitter {
|
|||||||
private stdoutRemainder: string = '';
|
private stdoutRemainder: string = '';
|
||||||
private stderrRemainder: 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();
|
||||||
this.options = options;
|
this.options = options;
|
||||||
@@ -73,6 +93,9 @@ export class ProcessWrapper extends EventEmitter {
|
|||||||
this.stdoutRemainder = '';
|
this.stdoutRemainder = '';
|
||||||
this.stderrRemainder = '';
|
this.stderrRemainder = '';
|
||||||
|
|
||||||
|
// Mark process reference as gone so isRunning() reflects reality
|
||||||
|
this.process = null;
|
||||||
|
|
||||||
this.emit('exit', code, signal);
|
this.emit('exit', code, signal);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,17 +216,13 @@ 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}`);
|
||||||
try {
|
await this.killProcessTree('SIGTERM');
|
||||||
// Try to signal the whole process group on POSIX to ensure children get the signal too
|
|
||||||
if (process.platform !== 'win32') {
|
// If the process already exited, return immediately
|
||||||
process.kill(-Math.abs(this.process.pid), 'SIGTERM');
|
if (typeof this.process.exitCode === 'number') {
|
||||||
} else {
|
this.logger.debug('Process already exited, no need to wait');
|
||||||
process.kill(this.process.pid, 'SIGTERM');
|
return;
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fallback to direct process kill if group kill fails
|
|
||||||
process.kill(this.process.pid, 'SIGTERM');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for exit or escalate
|
// Wait for exit or escalate
|
||||||
@@ -218,26 +237,15 @@ export class ProcessWrapper extends EventEmitter {
|
|||||||
const onExit = () => cleanup();
|
const onExit = () => cleanup();
|
||||||
this.process!.once('exit', onExit);
|
this.process!.once('exit', onExit);
|
||||||
|
|
||||||
const killTimer = setTimeout(() => {
|
const killTimer = setTimeout(async () => {
|
||||||
if (!this.process || !this.process.pid) return cleanup();
|
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 {
|
||||||
if (process.platform !== 'win32') {
|
await this.killProcessTree('SIGKILL');
|
||||||
process.kill(-Math.abs(this.process.pid), 'SIGKILL');
|
} catch {}
|
||||||
} else {
|
|
||||||
process.kill(this.process.pid, 'SIGKILL');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Failed to send SIGKILL, process probably already exited: ${error?.message || String(error)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Give a short grace period after SIGKILL
|
// Give a short grace period after SIGKILL
|
||||||
setTimeout(() => cleanup(), 500);
|
setTimeout(() => cleanup(), 500);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
@@ -264,6 +272,7 @@ export class ProcessWrapper extends EventEmitter {
|
|||||||
* Get the process ID if running
|
* Get the process ID if running
|
||||||
*/
|
*/
|
||||||
public getPid(): number | null {
|
public getPid(): number | null {
|
||||||
|
if (!this.isRunning()) return null;
|
||||||
return this.process?.pid || null;
|
return this.process?.pid || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +296,13 @@ export class ProcessWrapper extends EventEmitter {
|
|||||||
* Check if the process is currently running
|
* Check if the process is currently running
|
||||||
*/
|
*/
|
||||||
public isRunning(): boolean {
|
public isRunning(): boolean {
|
||||||
return this.process !== null && typeof this.process.exitCode !== 'number';
|
if (!this.process) return false;
|
||||||
|
// In Node, while the child is running: exitCode === null and signalCode === null/undefined
|
||||||
|
// After it exits: exitCode is a number OR signalCode is a string
|
||||||
|
const anyProc: any = this.process as any;
|
||||||
|
const exitCode = anyProc.exitCode;
|
||||||
|
const signalCode = anyProc.signalCode;
|
||||||
|
return exitCode === null && (signalCode === null || typeof signalCode === 'undefined');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -10,6 +10,7 @@ import type {
|
|||||||
DaemonStatusResponse,
|
DaemonStatusResponse,
|
||||||
HeartbeatResponse,
|
HeartbeatResponse,
|
||||||
} from '../shared/protocol/ipc.types.js';
|
} from '../shared/protocol/ipc.types.js';
|
||||||
|
import { LogPersistence } from './logpersistence.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Central daemon server that manages all TSPM processes
|
* Central daemon server that manages all TSPM processes
|
||||||
@@ -170,7 +171,22 @@ export class TspmDaemon {
|
|||||||
throw new Error(`Process ${id} not found`);
|
throw new Error(`Process ${id} not found`);
|
||||||
}
|
}
|
||||||
await this.tspmInstance.setDesiredState(id, 'online');
|
await this.tspmInstance.setDesiredState(id, 'online');
|
||||||
await this.tspmInstance.start(config);
|
const existing = this.tspmInstance.processes.get(id);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.isRunning()) {
|
||||||
|
// Already running; return current status/pid
|
||||||
|
const runningInfo = this.tspmInstance.processInfo.get(id);
|
||||||
|
return {
|
||||||
|
processId: id,
|
||||||
|
pid: runningInfo?.pid,
|
||||||
|
status: runningInfo?.status || 'online',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
await this.tspmInstance.restart(id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.tspmInstance.start(config);
|
||||||
|
}
|
||||||
const processInfo = this.tspmInstance.processInfo.get(id);
|
const processInfo = this.tspmInstance.processInfo.get(id);
|
||||||
return {
|
return {
|
||||||
processId: id,
|
processId: id,
|
||||||
@@ -450,10 +466,12 @@ export class TspmDaemon {
|
|||||||
|
|
||||||
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' });
|
||||||
@@ -499,6 +517,28 @@ export class TspmDaemon {
|
|||||||
'daemon:status',
|
'daemon:status',
|
||||||
async (request: RequestForMethod<'daemon:status'>) => {
|
async (request: RequestForMethod<'daemon:status'>) => {
|
||||||
const memUsage = process.memoryUsage();
|
const memUsage = process.memoryUsage();
|
||||||
|
// Aggregate log stats from monitors
|
||||||
|
let totalLogCount = 0;
|
||||||
|
let totalLogBytes = 0;
|
||||||
|
const perProcess: Array<{ id: ProcessId; count: number; bytes: number }> = [];
|
||||||
|
for (const [id, monitor] of this.tspmInstance.processes.entries()) {
|
||||||
|
try {
|
||||||
|
const logs = monitor.getLogs();
|
||||||
|
const count = logs.length;
|
||||||
|
const bytes = LogPersistence.calculateLogMemorySize(logs);
|
||||||
|
totalLogCount += count;
|
||||||
|
totalLogBytes += bytes;
|
||||||
|
perProcess.push({ id, count, bytes });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const pathsInfo = {
|
||||||
|
tspmDir: paths.tspmDir,
|
||||||
|
socketPath: this.socketPath,
|
||||||
|
pidFile: this.daemonPidFile,
|
||||||
|
};
|
||||||
|
const configsInfo = {
|
||||||
|
processConfigs: this.tspmInstance.processConfigs.size,
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
status: 'running',
|
status: 'running',
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
@@ -507,6 +547,13 @@ export class TspmDaemon {
|
|||||||
memoryUsage: memUsage.heapUsed,
|
memoryUsage: memUsage.heapUsed,
|
||||||
cpuUsage: process.cpuUsage().user / 1000000, // Convert to seconds
|
cpuUsage: process.cpuUsage().user / 1000000, // Convert to seconds
|
||||||
version: this.version,
|
version: this.version,
|
||||||
|
logsInMemory: {
|
||||||
|
totalCount: totalLogCount,
|
||||||
|
totalBytes: totalLogBytes,
|
||||||
|
perProcess,
|
||||||
|
},
|
||||||
|
paths: pathsInfo,
|
||||||
|
configs: configsInfo,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@@ -228,6 +228,20 @@ export interface DaemonStatusResponse {
|
|||||||
memoryUsage?: number;
|
memoryUsage?: number;
|
||||||
cpuUsage?: number;
|
cpuUsage?: number;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
// Additional metadata (optional)
|
||||||
|
paths?: {
|
||||||
|
tspmDir?: string;
|
||||||
|
socketPath?: string;
|
||||||
|
pidFile?: string;
|
||||||
|
};
|
||||||
|
configs?: {
|
||||||
|
processConfigs?: number;
|
||||||
|
};
|
||||||
|
logsInMemory?: {
|
||||||
|
totalCount: number;
|
||||||
|
totalBytes: number;
|
||||||
|
perProcess: Array<{ id: ProcessId; count: number; bytes: number }>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Daemon shutdown command
|
// Daemon shutdown command
|
||||||
|
Reference in New Issue
Block a user