feat(cli): Add interactive edit command and update support for process configurations
This commit is contained in:
		@@ -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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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'
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										117
									
								
								ts/cli/commands/process/edit.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								ts/cli/commands/process/edit.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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 <id>');
 | 
			
		||||
        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' },
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -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<void> => {
 | 
			
		||||
  registerListCommand(smartcliInstance);
 | 
			
		||||
  registerDescribeCommand(smartcliInstance);
 | 
			
		||||
  registerLogsCommand(smartcliInstance);
 | 
			
		||||
  registerEditCommand(smartcliInstance);
 | 
			
		||||
 | 
			
		||||
  // Batch commands
 | 
			
		||||
  registerStartAllCommand(smartcliInstance);
 | 
			
		||||
 
 | 
			
		||||
@@ -197,6 +197,32 @@ 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
 | 
			
		||||
   */
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
        });
 | 
			
		||||
 
 | 
			
		||||
@@ -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'>) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -249,6 +249,17 @@ export interface RemoveResponse {
 | 
			
		||||
  message?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Update (modify existing config)
 | 
			
		||||
export interface UpdateRequest {
 | 
			
		||||
  id: ProcessId;
 | 
			
		||||
  updates: Partial<Omit<IProcessConfig, 'id'>>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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 };
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user