From 311a536fae130c1ac6cb16a4acf48cdfa4af97a8 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sat, 30 Aug 2025 14:02:22 +0000 Subject: [PATCH] feat(cli): Add interactive edit command and update support for process configurations --- changelog.md | 9 +++ ts/00_commitinfo_data.ts | 2 +- ts/cli/commands/process/add.ts | 3 + ts/cli/commands/process/edit.ts | 117 ++++++++++++++++++++++++++++++++ ts/cli/index.ts | 2 + ts/daemon/processmanager.ts | 26 +++++++ ts/daemon/processwrapper.ts | 4 +- ts/daemon/tspm.daemon.ts | 13 ++++ ts/shared/protocol/ipc.types.ts | 12 ++++ 9 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 ts/cli/commands/process/edit.ts diff --git a/changelog.md b/changelog.md index 05b2a60..6927267 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 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 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index f6e1f8a..b8d9924 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: '5.0.0', + version: '5.1.0', description: 'a no fuzz process manager' } diff --git a/ts/cli/commands/process/add.ts b/ts/cli/commands/process/add.ts index df5478a..9739d37 100644 --- a/ts/cli/commands/process/add.ts +++ b/ts/cli/commands/process/add.ts @@ -76,6 +76,9 @@ export function registerAddCommand(smartcli: plugins.smartcli.Smartcli) { args: cmdArgs, projectDir, memoryLimitBytes: memoryLimit, + // Persist the PATH from the current CLI environment so managed + // processes see the same PATH they had when added. + env: { PATH: process.env.PATH || '' }, autorestart, watch, watchPaths, diff --git a/ts/cli/commands/process/edit.ts b/ts/cli/commands/process/edit.ts new file mode 100644 index 0000000..b602a0b --- /dev/null +++ b/ts/cli/commands/process/edit.ts @@ -0,0 +1,117 @@ +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 idRaw = argvArg._[1]; + if (!idRaw) { + console.error('Error: Please provide a process ID to edit'); + console.log('Usage: tspm edit '); + return; + } + + const id = idRaw; + + // Load current config + const { config } = await tspmIpcClient.request('describe', { id }); + + const si = plugins.smartinteract; + + const answers: any = {}; + answers.name = await si.question.text( + `Name [${config.name || ''}]`, + config.name || '', + ); + answers.command = await si.question.text( + `Command [${config.command}]`, + config.command, + ); + const currentArgs = (config.args || []).join(' '); + const argsStr = await si.question.text( + `Args (space separated) [${currentArgs}]`, + currentArgs, + ); + answers.args = argsStr.trim() ? argsStr.split(/\s+/) : []; + answers.projectDir = await si.question.text( + `Working directory [${config.projectDir}]`, + config.projectDir, + ); + const memStrDefault = formatMemory(config.memoryLimitBytes); + const memStr = await si.question.text( + `Memory limit [${memStrDefault}]`, + memStrDefault, + ); + answers.memoryLimitBytes = parseMemoryString(memStr || memStrDefault); + answers.autorestart = await si.question.confirm( + `Autorestart? [${config.autorestart ? 'Y' : 'N'}]`, + !!config.autorestart, + ); + const watchEnabled = await si.question.confirm( + `Watch for changes? [${config.watch ? 'Y' : 'N'}]`, + !!config.watch, + ); + answers.watch = watchEnabled; + if (watchEnabled) { + const existingWatch = (config.watchPaths || []).join(','); + const watchStr = await si.question.text( + `Watch paths (comma separated) [${existingWatch}]`, + existingWatch, + ); + answers.watchPaths = watchStr + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + } + + const replacePath = await si.question.confirm( + 'Replace stored PATH with current PATH?', + false, + ); + + const updates: any = {}; + if (answers.name !== config.name) updates.name = answers.name; + if (answers.command !== config.command) updates.command = answers.command; + if (JSON.stringify(answers.args) !== JSON.stringify(config.args || [])) + updates.args = answers.args.length ? answers.args : undefined; + if (answers.projectDir !== config.projectDir) + updates.projectDir = answers.projectDir; + if (answers.memoryLimitBytes !== config.memoryLimitBytes) + updates.memoryLimitBytes = answers.memoryLimitBytes; + if (answers.autorestart !== config.autorestart) + updates.autorestart = answers.autorestart; + if (answers.watch !== config.watch) updates.watch = answers.watch; + if (answers.watch && JSON.stringify(answers.watchPaths || []) !== JSON.stringify(config.watchPaths || [])) + updates.watchPaths = answers.watchPaths; + + if (replacePath) { + updates.env = { ...(config.env || {}), PATH: process.env.PATH || '' }; + } + + if (Object.keys(updates).length === 0) { + console.log('No changes. Nothing to update.'); + return; + } + + const { config: newConfig } = await tspmIpcClient.request('update', { + id, + updates, + } as any); + + console.log('✓ Updated process configuration'); + console.log(` ID: ${newConfig.id}`); + console.log(` Command: ${newConfig.command}`); + console.log(` CWD: ${newConfig.projectDir}`); + if (newConfig.env?.PATH) { + console.log(' PATH: [stored]'); + } + }, + { actionLabel: 'edit process config' }, + ); +} + diff --git a/ts/cli/index.ts b/ts/cli/index.ts index eb637c6..1b05149 100644 --- a/ts/cli/index.ts +++ b/ts/cli/index.ts @@ -13,6 +13,7 @@ import { registerDeleteCommand } from './commands/process/delete.js'; import { registerListCommand } from './commands/process/list.js'; import { registerDescribeCommand } from './commands/process/describe.js'; import { registerLogsCommand } from './commands/process/logs.js'; +import { registerEditCommand } from './commands/process/edit.js'; import { registerStartAllCommand } from './commands/batch/start-all.js'; import { registerStopAllCommand } from './commands/batch/stop-all.js'; import { registerRestartAllCommand } from './commands/batch/restart-all.js'; @@ -72,6 +73,7 @@ export const run = async (): Promise => { registerListCommand(smartcliInstance); registerDescribeCommand(smartcliInstance); registerLogsCommand(smartcliInstance); + registerEditCommand(smartcliInstance); // Batch commands registerStartAllCommand(smartcliInstance); diff --git a/ts/daemon/processmanager.ts b/ts/daemon/processmanager.ts index ba81c81..57a3380 100644 --- a/ts/daemon/processmanager.ts +++ b/ts/daemon/processmanager.ts @@ -197,6 +197,32 @@ export class ProcessManager extends EventEmitter { } } + /** + * Update an existing process configuration + */ + public async update( + id: ProcessId, + updates: Partial>, + ): Promise { + 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 */ diff --git a/ts/daemon/processwrapper.ts b/ts/daemon/processwrapper.ts index 8a9c843..525aea9 100644 --- a/ts/daemon/processwrapper.ts +++ b/ts/daemon/processwrapper.ts @@ -47,7 +47,7 @@ export class ProcessWrapper extends EventEmitter { this.options.args, { 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 }, ); @@ -55,7 +55,7 @@ export class ProcessWrapper extends EventEmitter { // Use shell mode to allow a full command string this.process = plugins.childProcess.spawn(this.options.command, { 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 shell: true, }); diff --git a/ts/daemon/tspm.daemon.ts b/ts/daemon/tspm.daemon.ts index 306fd81..9088415 100644 --- a/ts/daemon/tspm.daemon.ts +++ b/ts/daemon/tspm.daemon.ts @@ -233,6 +233,19 @@ export class TspmDaemon { }, ); + this.ipcServer.onMessage( + 'update', + async (request: RequestForMethod<'update'>) => { + try { + const id = toProcessId(request.id); + const updated = await this.tspmInstance.update(id, request.updates as any); + return { id, config: updated }; + } catch (error) { + throw new Error(`Failed to update process: ${error.message}`); + } + }, + ); + this.ipcServer.onMessage( 'remove', async (request: RequestForMethod<'remove'>) => { diff --git a/ts/shared/protocol/ipc.types.ts b/ts/shared/protocol/ipc.types.ts index ae42158..a90bcc7 100644 --- a/ts/shared/protocol/ipc.types.ts +++ b/ts/shared/protocol/ipc.types.ts @@ -249,6 +249,17 @@ export interface RemoveResponse { message?: string; } +// Update (modify existing config) +export interface UpdateRequest { + id: ProcessId; + updates: Partial>; +} + +export interface UpdateResponse { + id: ProcessId; + config: IProcessConfig; +} + // Type mappings for methods export type IpcMethodMap = { start: { request: StartRequest; response: StartResponse }; @@ -257,6 +268,7 @@ export type IpcMethodMap = { restart: { request: RestartRequest; response: RestartResponse }; delete: { request: DeleteRequest; response: DeleteResponse }; add: { request: AddRequest; response: AddResponse }; + update: { request: UpdateRequest; response: UpdateResponse }; remove: { request: RemoveRequest; response: RemoveResponse }; list: { request: ListRequest; response: ListResponse }; describe: { request: DescribeRequest; response: DescribeResponse };