feat(cli): Add interactive edit command and update support for process configurations
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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)
|
## 2025-08-30 - 5.0.0 - BREAKING CHANGE(daemon)
|
||||||
Introduce persistent log storage, numeric ProcessId type, and improved process monitoring / IPC handling
|
Introduce persistent log storage, numeric ProcessId type, and improved process monitoring / IPC handling
|
||||||
|
|
||||||
|
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@git.zone/tspm',
|
name: '@git.zone/tspm',
|
||||||
version: '5.0.0',
|
version: '5.1.0',
|
||||||
description: 'a no fuzz process manager'
|
description: 'a no fuzz process manager'
|
||||||
}
|
}
|
||||||
|
@@ -76,6 +76,9 @@ export function registerAddCommand(smartcli: plugins.smartcli.Smartcli) {
|
|||||||
args: cmdArgs,
|
args: cmdArgs,
|
||||||
projectDir,
|
projectDir,
|
||||||
memoryLimitBytes: memoryLimit,
|
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,
|
autorestart,
|
||||||
watch,
|
watch,
|
||||||
watchPaths,
|
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 { registerListCommand } from './commands/process/list.js';
|
||||||
import { registerDescribeCommand } from './commands/process/describe.js';
|
import { registerDescribeCommand } from './commands/process/describe.js';
|
||||||
import { registerLogsCommand } from './commands/process/logs.js';
|
import { registerLogsCommand } from './commands/process/logs.js';
|
||||||
|
import { registerEditCommand } from './commands/process/edit.js';
|
||||||
import { registerStartAllCommand } from './commands/batch/start-all.js';
|
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';
|
||||||
@@ -72,6 +73,7 @@ export const run = async (): Promise<void> => {
|
|||||||
registerListCommand(smartcliInstance);
|
registerListCommand(smartcliInstance);
|
||||||
registerDescribeCommand(smartcliInstance);
|
registerDescribeCommand(smartcliInstance);
|
||||||
registerLogsCommand(smartcliInstance);
|
registerLogsCommand(smartcliInstance);
|
||||||
|
registerEditCommand(smartcliInstance);
|
||||||
|
|
||||||
// Batch commands
|
// Batch commands
|
||||||
registerStartAllCommand(smartcliInstance);
|
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
|
* Stop a process by id
|
||||||
*/
|
*/
|
||||||
|
@@ -47,7 +47,7 @@ export class ProcessWrapper extends EventEmitter {
|
|||||||
this.options.args,
|
this.options.args,
|
||||||
{
|
{
|
||||||
cwd: this.options.cwd,
|
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
|
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
|
// Use shell mode to allow a full command string
|
||||||
this.process = plugins.childProcess.spawn(this.options.command, {
|
this.process = plugins.childProcess.spawn(this.options.command, {
|
||||||
cwd: this.options.cwd,
|
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
|
stdio: ['ignore', 'pipe', 'pipe'], // We need to pipe stdout and stderr
|
||||||
shell: true,
|
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(
|
this.ipcServer.onMessage(
|
||||||
'remove',
|
'remove',
|
||||||
async (request: RequestForMethod<'remove'>) => {
|
async (request: RequestForMethod<'remove'>) => {
|
||||||
|
@@ -249,6 +249,17 @@ export interface RemoveResponse {
|
|||||||
message?: string;
|
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
|
// Type mappings for methods
|
||||||
export type IpcMethodMap = {
|
export type IpcMethodMap = {
|
||||||
start: { request: StartRequest; response: StartResponse };
|
start: { request: StartRequest; response: StartResponse };
|
||||||
@@ -257,6 +268,7 @@ export type IpcMethodMap = {
|
|||||||
restart: { request: RestartRequest; response: RestartResponse };
|
restart: { request: RestartRequest; response: RestartResponse };
|
||||||
delete: { request: DeleteRequest; response: DeleteResponse };
|
delete: { request: DeleteRequest; response: DeleteResponse };
|
||||||
add: { request: AddRequest; response: AddResponse };
|
add: { request: AddRequest; response: AddResponse };
|
||||||
|
update: { request: UpdateRequest; response: UpdateResponse };
|
||||||
remove: { request: RemoveRequest; response: RemoveResponse };
|
remove: { request: RemoveRequest; response: RemoveResponse };
|
||||||
list: { request: ListRequest; response: ListResponse };
|
list: { request: ListRequest; response: ListResponse };
|
||||||
describe: { request: DescribeRequest; response: DescribeResponse };
|
describe: { request: DescribeRequest; response: DescribeResponse };
|
||||||
|
Reference in New Issue
Block a user