diff --git a/changelog.md b/changelog.md index 4e7d679..60968a0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2025-08-29 - 4.0.0 - BREAKING CHANGE(cli) +Add persistent process registration (tspm add), alias remove, and change start to use saved process IDs (breaking CLI behavior) + +- Add a new CLI command `tspm add` that registers a process configuration without starting it; daemon assigns a sequential numeric ID and returns the stored config. +- Change `tspm start` to accept a process ID and start the saved configuration instead of accepting ad-hoc commands/files. This is a breaking change to the CLI contract. +- Add `remove` as an alias for the existing `delete` command; both CLI and daemon now support `remove` which stops and deletes the stored process. +- Daemon and IPC protocol updated to support `add` and `remove` methods; shared IPC types extended accordingly. +- ProcessManager: implemented add() and getNextSequentialId() to persist configs and produce numeric IDs. +- CLI registration updated (registerIpcCommand) to accept multiple command names, enabling aliases for commands. + ## 2025-08-29 - 3.1.3 - fix(client) Improve IPC client robustness and daemon debug logging; update tests and package metadata diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 1ad9c64..61cf220 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@git.zone/tspm', - version: '3.1.3', + version: '4.0.0', description: 'a no fuzz process manager' } diff --git a/ts/cli/commands/process/add.ts b/ts/cli/commands/process/add.ts new file mode 100644 index 0000000..fc8a9fe --- /dev/null +++ b/ts/cli/commands/process/add.ts @@ -0,0 +1,91 @@ +import * as plugins from '../../../plugins.js'; +import { tspmIpcClient } from '../../../client/tspm.ipcclient.js'; +import type { CliArguments } from '../../types.js'; +import { parseMemoryString, formatMemory } from '../../helpers/memory.js'; +import { registerIpcCommand } from '../../registration/index.js'; + +export function registerAddCommand(smartcli: plugins.smartcli.Smartcli) { + registerIpcCommand( + smartcli, + 'add', + async (argvArg: CliArguments) => { + const args = argvArg._.slice(1); + if (args.length === 0) { + console.error('Error: Please provide a command or .ts file'); + console.log('Usage: tspm add [options]'); + console.log('\nOptions:'); + console.log(' --name Optional name'); + console.log(' --memory Memory limit (e.g., 512MB, 2GB)'); + console.log(' --cwd Working directory'); + console.log(' --watch Watch for file changes'); + console.log(' --watch-paths Comma-separated paths'); + console.log(' --autorestart Auto-restart on crash (default true)'); + return; + } + + const script = args.join(' '); + const projectDir = argvArg.cwd || process.cwd(); + const memoryLimit = argvArg.memory + ? parseMemoryString(argvArg.memory) + : 512 * 1024 * 1024; + + // Resolve .ts single-file execution via tsx if needed + const parts = script.split(' '); + const first = parts[0]; + let command = script; + let cmdArgs: string[] | undefined; + if (parts.length === 1 && first.endsWith('.ts')) { + try { + const { createRequire } = await import('module'); + const require = createRequire(import.meta.url); + const tsxPath = require.resolve('tsx/dist/cli.mjs'); + const filePath = plugins.path.isAbsolute(first) + ? first + : plugins.path.join(projectDir, first); + command = tsxPath; + cmdArgs = [filePath]; + } catch { + command = 'tsx'; + cmdArgs = [first]; + } + } + + const name = argvArg.name || script; + const watch = argvArg.watch || false; + const autorestart = argvArg.autorestart !== false; + const watchPaths = argvArg.watchPaths + ? typeof argvArg.watchPaths === 'string' + ? (argvArg.watchPaths as string).split(',') + : argvArg.watchPaths + : undefined; + + console.log('Adding process configuration:'); + console.log(` Command: ${script}${parts.length === 1 && first.endsWith('.ts') ? ' (via tsx)' : ''}`); + console.log(` Directory: ${projectDir}`); + console.log(` Memory limit: ${formatMemory(memoryLimit)}`); + console.log(` Auto-restart: ${autorestart}`); + if (watch) { + console.log(` Watch: enabled`); + if (watchPaths) console.log(` Watch paths: ${watchPaths.join(',')}`); + } + + const response = await tspmIpcClient.request('add', { + config: { + name, + command, + args: cmdArgs, + projectDir, + memoryLimitBytes: memoryLimit, + autorestart, + watch, + watchPaths, + }, + }); + + console.log('✓ Added'); + console.log(` Assigned ID: ${response.id}`); + }, + { actionLabel: 'add process config' }, + ); +} + diff --git a/ts/cli/commands/process/delete.ts b/ts/cli/commands/process/delete.ts index 65dc4c0..6bfe2e1 100644 --- a/ts/cli/commands/process/delete.ts +++ b/ts/cli/commands/process/delete.ts @@ -6,24 +6,27 @@ import { registerIpcCommand } from '../../registration/index.js'; export function registerDeleteCommand(smartcli: plugins.smartcli.Smartcli) { registerIpcCommand( smartcli, - 'delete', + ['delete', 'remove'], async (argvArg: CliArguments) => { const id = argvArg._[1]; if (!id) { console.error('Error: Please provide a process ID'); - console.log('Usage: tspm delete '); + console.log('Usage: tspm delete | tspm remove '); return; } - console.log(`Deleting process: ${id}`); - const response = await tspmIpcClient.request('delete', { id }); + // Determine if command was 'remove' to use the new IPC route, otherwise 'delete' + const cmd = String(argvArg._[0]); + const useRemove = cmd === 'remove'; + console.log(`${useRemove ? 'Removing' : 'Deleting'} process: ${id}`); + const response = await tspmIpcClient.request(useRemove ? 'remove' : 'delete', { id } as any); if (response.success) { - console.log(`✓ ${response.message}`); + console.log(`✓ ${response.message || (useRemove ? 'Removed successfully' : 'Deleted successfully')}`); } else { - console.error(`✗ Failed to delete process: ${response.message}`); + console.error(`✗ Failed to ${useRemove ? 'remove' : 'delete'} process: ${response.message}`); } }, - { actionLabel: 'delete process' }, + { actionLabel: 'delete/remove process' }, ); } diff --git a/ts/cli/commands/process/start.ts b/ts/cli/commands/process/start.ts index f26c433..d83dfe5 100644 --- a/ts/cli/commands/process/start.ts +++ b/ts/cli/commands/process/start.ts @@ -10,108 +10,22 @@ export function registerStartCommand(smartcli: plugins.smartcli.Smartcli) { smartcli, 'start', async (argvArg: CliArguments) => { - // Get all arguments after 'start' command - const commandArgs = argvArg._.slice(1); - if (commandArgs.length === 0) { - console.error('Error: Please provide a command to run'); - console.log('Usage: tspm start [options]'); - console.log('\nExamples:'); - console.log(' tspm start "npm run dev"'); - console.log(' tspm start pnpm start'); - console.log(' tspm start node server.js'); - console.log(' tspm start script.ts'); - console.log('\nOptions:'); - console.log(' --name Name for the process'); - console.log( - ' --memory Memory limit (e.g., "512MB", "2GB")', - ); - console.log(' --cwd Working directory'); - console.log( - ' --watch Watch for file changes and restart', - ); - console.log(' --watch-paths Comma-separated paths to watch'); - console.log(' --autorestart Auto-restart on crash'); + const id = argvArg._[1]; + if (!id) { + console.error('Error: Please provide a process ID to start'); + console.log('Usage: tspm start '); return; } - - // Join all command parts to form the full command - const script = commandArgs.join(' '); - const memoryLimit = argvArg.memory - ? parseMemoryString(argvArg.memory) - : 512 * 1024 * 1024; - const projectDir = argvArg.cwd || process.cwd(); - - // Parse the command to determine if we need to handle .ts files - let actualCommand: string; - let processArgs: string[] | undefined = undefined; - - // Split the script to check if it's a single .ts file or a full command - const scriptParts = script.split(' '); - const firstPart = scriptParts[0]; - - // Check if this is a direct .ts file execution (single argument ending in .ts) - if (scriptParts.length === 1 && firstPart.endsWith('.ts')) { - try { - const tsxPath = await (async () => { - const { createRequire } = await import('module'); - const require = createRequire(import.meta.url); - return require.resolve('tsx/dist/cli.mjs'); - })(); - - const scriptPath = plugins.path.isAbsolute(firstPart) - ? firstPart - : plugins.path.join(projectDir, firstPart); - actualCommand = tsxPath; - processArgs = [scriptPath]; - } catch { - actualCommand = 'tsx'; - processArgs = [firstPart]; - } - } else { - // For multi-word commands, use the entire script as the command - // This handles cases like "pnpm start", "npm run dev", etc. - actualCommand = script; - processArgs = undefined; + const desc = await tspmIpcClient.request('describe', { id }).catch(() => null); + if (!desc) { + console.error(`Process with id '${id}' not found. Use 'tspm add' first.`); + return; } - const name = argvArg.name || script; - const watch = argvArg.watch || false; - const autorestart = argvArg.autorestart !== false; // default true - const watchPaths = argvArg.watchPaths - ? typeof argvArg.watchPaths === 'string' - ? (argvArg.watchPaths as string).split(',') - : argvArg.watchPaths - : undefined; - - const processConfig: IProcessConfig = { - id: name.replace(/[^a-zA-Z0-9-_]/g, '_'), - name, - command: actualCommand, - args: processArgs, - projectDir, - memoryLimitBytes: memoryLimit, - autorestart, - watch, - watchPaths, - }; - - console.log(`Starting process: ${name}`); - console.log( - ` Command: ${script}${scriptParts.length === 1 && firstPart.endsWith('.ts') ? ' (via tsx)' : ''}`, - ); - console.log(` Directory: ${projectDir}`); - console.log(` Memory limit: ${formatMemory(memoryLimit)}`); - console.log(` Auto-restart: ${autorestart}`); - if (watch) { - console.log(` Watch mode: enabled`); - if (watchPaths) console.log(` Watch paths: ${watchPaths.join(', ')}`); - } - - const response = await tspmIpcClient.request('start', { - config: processConfig, - }); - console.log(`✓ Process started successfully`); + console.log(`Starting process id ${id} (${desc.config.name || id})...`); + const response = await tspmIpcClient.request('start', { config: desc.config }); + console.log('✓ Process started'); console.log(` ID: ${response.processId}`); console.log(` PID: ${response.pid || 'N/A'}`); console.log(` Status: ${response.status}`); diff --git a/ts/cli/index.ts b/ts/cli/index.ts index 888299b..c3761d5 100644 --- a/ts/cli/index.ts +++ b/ts/cli/index.ts @@ -5,6 +5,7 @@ import { Logger, LogLevel } from '../shared/common/utils.errorhandler.js'; // Import command registration functions import { registerDefaultCommand } from './commands/default.js'; import { registerStartCommand } from './commands/process/start.js'; +import { registerAddCommand } from './commands/process/add.js'; import { registerStopCommand } from './commands/process/stop.js'; import { registerRestartCommand } from './commands/process/restart.js'; import { registerDeleteCommand } from './commands/process/delete.js'; @@ -43,6 +44,7 @@ export const run = async (): Promise => { registerDefaultCommand(smartcliInstance); // Process commands + registerAddCommand(smartcliInstance); registerStartCommand(smartcliInstance); registerStopCommand(smartcliInstance); registerRestartCommand(smartcliInstance); diff --git a/ts/cli/registration/index.ts b/ts/cli/registration/index.ts index 40d561c..029b5f4 100644 --- a/ts/cli/registration/index.ts +++ b/ts/cli/registration/index.ts @@ -17,53 +17,56 @@ import { ensureDaemonOrHint } from './daemon-check.js'; */ export function registerIpcCommand( smartcli: plugins.smartcli.Smartcli, - name: string, + name: string | string[], action: CommandAction, opts: IpcCommandOptions = {}, ) { - const { actionLabel = name, keepAlive = false, requireDaemon = true } = opts; + const names = Array.isArray(name) ? name : [name]; + for (const singleName of names) { + const { actionLabel = singleName, keepAlive = false, requireDaemon = true } = opts; - smartcli.addCommand(name).subscribe({ - next: async (argv: CliArguments) => { - // Early preflight for better UX - const ok = await ensureDaemonOrHint(requireDaemon, actionLabel); - if (!ok) { - process.exit(1); - return; - } - - // Evaluate keepAlive - can be boolean or function - const shouldKeepAlive = - typeof keepAlive === 'function' ? keepAlive(argv) : keepAlive; - - if (shouldKeepAlive) { - // Let action manage its own connection/cleanup lifecycle - try { - await action(argv); - } catch (error) { - handleDaemonError(error, actionLabel); + smartcli.addCommand(singleName).subscribe({ + next: async (argv: CliArguments) => { + // Early preflight for better UX + const ok = await ensureDaemonOrHint(requireDaemon, actionLabel); + if (!ok) { + process.exit(1); + return; } - } else { - // Auto-disconnect pattern for one-shot IPC commands - await runIpcCommand(async () => { + + // Evaluate keepAlive - can be boolean or function + const shouldKeepAlive = + typeof keepAlive === 'function' ? keepAlive(argv) : keepAlive; + + if (shouldKeepAlive) { + // Let action manage its own connection/cleanup lifecycle try { await action(argv); } catch (error) { handleDaemonError(error, actionLabel); } - }); - } - }, - error: (err) => { - // Fallback error path (should be rare with try/catch in next) - console.error( - `Unexpected error in command "${name}":`, - unknownError(err), - ); - process.exit(1); - }, - complete: () => {}, - }); + } else { + // Auto-disconnect pattern for one-shot IPC commands + await runIpcCommand(async () => { + try { + await action(argv); + } catch (error) { + handleDaemonError(error, actionLabel); + } + }); + } + }, + error: (err) => { + // Fallback error path (should be rare with try/catch in next) + console.error( + `Unexpected error in command "${singleName}":`, + unknownError(err), + ); + process.exit(1); + }, + complete: () => {}, + }); + } } /** diff --git a/ts/daemon/processmanager.ts b/ts/daemon/processmanager.ts index d098803..800d18b 100644 --- a/ts/daemon/processmanager.ts +++ b/ts/daemon/processmanager.ts @@ -34,6 +34,42 @@ export class ProcessManager extends EventEmitter { this.loadProcessConfigs(); } + /** + * Add a process configuration without starting it. + * Returns the assigned numeric sequential id as string. + */ + public async add(configInput: Omit & { id?: string }): Promise { + // Determine next numeric id + const nextId = this.getNextSequentialId(); + + const config: IProcessConfig = { + id: String(nextId), + name: configInput.name || `process-${nextId}`, + command: configInput.command, + args: configInput.args, + projectDir: configInput.projectDir, + memoryLimitBytes: configInput.memoryLimitBytes || 512 * 1024 * 1024, + monitorIntervalMs: configInput.monitorIntervalMs, + env: configInput.env, + logBufferSize: configInput.logBufferSize, + autorestart: configInput.autorestart ?? true, + watch: configInput.watch, + watchPaths: configInput.watchPaths, + }; + + // Store config and initial info + this.processConfigs.set(config.id, config); + this.processInfo.set(config.id, { + id: config.id, + status: 'stopped', + memory: 0, + restarts: 0, + }); + + await this.saveProcessConfigs(); + return config.id; + } + /** * Start a new process with the given configuration */ @@ -342,6 +378,20 @@ export class ProcessManager extends EventEmitter { } } + /** + * Compute next sequential numeric id based on existing configs + */ + private getNextSequentialId(): number { + let maxId = 0; + for (const id of this.processConfigs.keys()) { + const n = parseInt(id, 10); + if (!isNaN(n)) { + maxId = Math.max(maxId, n); + } + } + return maxId + 1; + } + /** * Save all process configurations to config storage */ diff --git a/ts/daemon/tspm.daemon.ts b/ts/daemon/tspm.daemon.ts index 11b4526..c2be9b2 100644 --- a/ts/daemon/tspm.daemon.ts +++ b/ts/daemon/tspm.daemon.ts @@ -171,6 +171,31 @@ export class TspmDaemon { ); // Query handlers + this.ipcServer.onMessage( + 'add', + async (request: RequestForMethod<'add'>) => { + try { + const id = await this.tspmInstance.add(request.config as any); + const config = this.tspmInstance.processConfigs.get(id)!; + return { id, config }; + } catch (error) { + throw new Error(`Failed to add process: ${error.message}`); + } + }, + ); + + this.ipcServer.onMessage( + 'remove', + async (request: RequestForMethod<'remove'>) => { + try { + await this.tspmInstance.delete(request.id); + return { success: true, message: `Process ${request.id} deleted successfully` }; + } catch (error) { + throw new Error(`Failed to remove process: ${error.message}`); + } + }, + ); + this.ipcServer.onMessage( 'list', async (request: RequestForMethod<'list'>) => { diff --git a/ts/shared/protocol/ipc.types.ts b/ts/shared/protocol/ipc.types.ts index 25eb0df..6f2c5c3 100644 --- a/ts/shared/protocol/ipc.types.ts +++ b/ts/shared/protocol/ipc.types.ts @@ -200,12 +200,35 @@ export interface HeartbeatResponse { status: 'healthy' | 'degraded'; } +// Add (register config without starting) +export interface AddRequest { + // Optional id is ignored server-side if present; server assigns sequential id + config: Omit & { id?: string }; +} + +export interface AddResponse { + id: string; + config: IProcessConfig; +} + +// Remove (delete config and stop if running) +export interface RemoveRequest { + id: string; +} + +export interface RemoveResponse { + success: boolean; + message?: string; +} + // Type mappings for methods export type IpcMethodMap = { start: { request: StartRequest; response: StartResponse }; stop: { request: StopRequest; response: StopResponse }; restart: { request: RestartRequest; response: RestartResponse }; delete: { request: DeleteRequest; response: DeleteResponse }; + add: { request: AddRequest; response: AddResponse }; + remove: { request: RemoveRequest; response: RemoveResponse }; list: { request: ListRequest; response: ListResponse }; describe: { request: DescribeRequest; response: DescribeResponse }; getLogs: { request: GetLogsRequest; response: GetLogsResponse };