Compare commits

...

6 Commits

Author SHA1 Message Date
9473924fcc 5.9.0
Some checks failed
Default (tags) / security (push) Successful in 51s
Default (tags) / test (push) Failing after 3m57s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-31 16:36:06 +00:00
a0e7408c1a feat(cli): Add interactive edit flow to CLI and improve UX 2025-08-31 16:36:06 +00:00
6e39b1db8f 5.8.0
Some checks failed
Default (tags) / security (push) Successful in 48s
Default (tags) / test (push) Failing after 3m57s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-31 08:08:27 +00:00
ee4532221a feat(core): Add core TypeScript TSPM implementation: CLI, daemon, client, process management and tests 2025-08-31 08:08:27 +00:00
e39173a827 5.7.0
Some checks failed
Default (tags) / security (push) Failing after 13m15s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-08-31 08:06:03 +00:00
6f14033d9b feat(cli): Add stats CLI command and daemon stats aggregation; fix process manager & wrapper state handling 2025-08-31 08:06:03 +00:00
16 changed files with 374 additions and 63 deletions

View File

@@ -1,5 +1,34 @@
# 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

View File

@@ -1,6 +1,6 @@
{
"name": "@git.zone/tspm",
"version": "5.6.2",
"version": "5.9.0",
"private": false,
"description": "a no fuzz process manager",
"main": "dist_ts/index.js",

View File

@@ -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-paths <paths>` - Comma-separated paths to watch
- `--autorestart` - Auto-restart on crash (default: true)
- `-i, --interactive` - Enter interactive edit mode after adding
**Examples:**
```bash
@@ -86,6 +87,9 @@ tspm add "tsx watch src/index.ts" --name dev-server --watch --watch-paths "src,c
# Add without auto-restart
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>`

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,9 @@ export function registerListCommand(smartcli: plugins.smartcli.Smartcli) {
const processes = response.processes;
if (processes.length === 0) {
console.log('No processes running.');
console.log('No processes configured.');
console.log('Use "tspm add <command>" to add one, e.g.:');
console.log(' tspm add "pnpm start"');
return;
}

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

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

View File

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

View File

@@ -20,6 +20,7 @@ import { registerStartAllCommand } from './commands/batch/start-all.js';
import { registerStopAllCommand } from './commands/batch/stop-all.js';
import { registerRestartAllCommand } from './commands/batch/restart-all.js';
import { registerDaemonCommand } from './commands/daemon/index.js';
import { registerStatsCommand } from './commands/stats.js';
import { registerEnableCommand } from './commands/service/enable.js';
import { registerDisableCommand } from './commands/service/disable.js';
import { registerResetCommand } from './commands/reset.js';
@@ -117,6 +118,7 @@ export const run = async (): Promise<void> => {
// Daemon commands
registerDaemonCommand(smartcliInstance);
registerStatsCommand(smartcliInstance);
// Service commands
registerEnableCommand(smartcliInstance);

View File

@@ -95,6 +95,16 @@ export class ProcessManager extends EventEmitter {
// Check if process with this id already exists
if (this.processes.has(config.id)) {
const existing = this.processes.get(config.id)!;
// If an existing monitor is present but not running, treat this as a fresh start via restart logic
if (!existing.isRunning()) {
this.logger.info(
`Existing monitor found for id '${config.id}' but not running. Restarting it...`,
);
await this.restart(config.id);
return;
}
// Already running surface a meaningful error
throw new ValidationError(
`Process with id '${config.id}' already exists`,
'ERR_DUPLICATE_PROCESS',
@@ -246,7 +256,8 @@ export class ProcessManager extends EventEmitter {
try {
await monitor.stop();
this.updateProcessInfo(id, { status: 'stopped' });
// Ensure status and PID are reflected immediately
this.updateProcessInfo(id, { status: 'stopped', pid: undefined });
this.logger.info(`Successfully stopped process with id '${id}'`);
} catch (error: Error | unknown) {
const processError = new ProcessError(
@@ -430,6 +441,8 @@ export class ProcessManager extends EventEmitter {
const pid = monitor.getPid();
if (pid) {
info.pid = pid;
} else {
info.pid = undefined;
}
// Update uptime if available
@@ -449,9 +462,7 @@ export class ProcessManager extends EventEmitter {
info.restarts = monitor.getRestartCount();
// Update status based on actual running state
if (monitor.isRunning()) {
info.status = 'online';
}
info.status = monitor.isRunning() ? 'online' : 'stopped';
}
}
@@ -728,7 +739,8 @@ export class ProcessManager extends EventEmitter {
throw configError;
}
} else {
this.logger.info('No saved process configurations found');
// First run / no configs yet — keep this quiet unless debugging
this.logger.debug('No saved process configurations found');
}
} catch (error: Error | unknown) {
// Only throw if it's not the "no configs found" case
@@ -737,9 +749,7 @@ export class ProcessManager extends EventEmitter {
}
// If no configs found or error reading, just continue with empty configs
this.logger.info(
'No saved process configurations found or error reading them',
);
this.logger.debug('No saved process configurations found or error reading them');
}
}

View File

@@ -93,6 +93,9 @@ export class ProcessWrapper extends EventEmitter {
this.stdoutRemainder = '';
this.stderrRemainder = '';
// Mark process reference as gone so isRunning() reflects reality
this.process = null;
this.emit('exit', code, signal);
});
@@ -269,6 +272,7 @@ export class ProcessWrapper extends EventEmitter {
* Get the process ID if running
*/
public getPid(): number | null {
if (!this.isRunning()) return null;
return this.process?.pid || null;
}
@@ -292,7 +296,13 @@ export class ProcessWrapper extends EventEmitter {
* Check if the process is currently running
*/
public isRunning(): boolean {
return this.process !== null && typeof this.process.exitCode !== 'number';
if (!this.process) return false;
// In Node, while the child is running: exitCode === null and signalCode === null/undefined
// After it exits: exitCode is a number OR signalCode is a string
const anyProc: any = this.process as any;
const exitCode = anyProc.exitCode;
const signalCode = anyProc.signalCode;
return exitCode === null && (signalCode === null || typeof signalCode === 'undefined');
}
/**

View File

@@ -10,6 +10,7 @@ import type {
DaemonStatusResponse,
HeartbeatResponse,
} from '../shared/protocol/ipc.types.js';
import { LogPersistence } from './logpersistence.js';
/**
* Central daemon server that manages all TSPM processes
@@ -170,7 +171,22 @@ export class TspmDaemon {
throw new Error(`Process ${id} not found`);
}
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);
return {
processId: id,
@@ -501,6 +517,28 @@ export class TspmDaemon {
'daemon:status',
async (request: RequestForMethod<'daemon:status'>) => {
const memUsage = process.memoryUsage();
// Aggregate log stats from monitors
let totalLogCount = 0;
let totalLogBytes = 0;
const perProcess: Array<{ id: ProcessId; count: number; bytes: number }> = [];
for (const [id, monitor] of this.tspmInstance.processes.entries()) {
try {
const logs = monitor.getLogs();
const count = logs.length;
const bytes = LogPersistence.calculateLogMemorySize(logs);
totalLogCount += count;
totalLogBytes += bytes;
perProcess.push({ id, count, bytes });
} catch {}
}
const pathsInfo = {
tspmDir: paths.tspmDir,
socketPath: this.socketPath,
pidFile: this.daemonPidFile,
};
const configsInfo = {
processConfigs: this.tspmInstance.processConfigs.size,
};
return {
status: 'running',
pid: process.pid,
@@ -509,6 +547,13 @@ export class TspmDaemon {
memoryUsage: memUsage.heapUsed,
cpuUsage: process.cpuUsage().user / 1000000, // Convert to seconds
version: this.version,
logsInMemory: {
totalCount: totalLogCount,
totalBytes: totalLogBytes,
perProcess,
},
paths: pathsInfo,
configs: configsInfo,
};
},
);

View File

@@ -228,6 +228,20 @@ export interface DaemonStatusResponse {
memoryUsage?: number;
cpuUsage?: number;
version?: string;
// Additional metadata (optional)
paths?: {
tspmDir?: string;
socketPath?: string;
pidFile?: string;
};
configs?: {
processConfigs?: number;
};
logsInMemory?: {
totalCount: number;
totalBytes: number;
perProcess: Array<{ id: ProcessId; count: number; bytes: number }>;
};
}
// Daemon shutdown command