BREAKING CHANGE(cli): Add persistent process registration (tspm add), alias remove, and change start to use saved process IDs (breaking CLI behavior)
This commit is contained in:
10
changelog.md
10
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
|
||||
|
||||
|
@@ -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'
|
||||
}
|
||||
|
91
ts/cli/commands/process/add.ts
Normal file
91
ts/cli/commands/process/add.ts
Normal file
@@ -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 <command|file.ts> [options]');
|
||||
console.log('\nOptions:');
|
||||
console.log(' --name <name> Optional name');
|
||||
console.log(' --memory <size> Memory limit (e.g., 512MB, 2GB)');
|
||||
console.log(' --cwd <path> Working directory');
|
||||
console.log(' --watch Watch for file changes');
|
||||
console.log(' --watch-paths <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' },
|
||||
);
|
||||
}
|
||||
|
@@ -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 <id>');
|
||||
console.log('Usage: tspm delete <id> | tspm remove <id>');
|
||||
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' },
|
||||
);
|
||||
}
|
||||
|
@@ -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 <command> [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> Name for the process');
|
||||
console.log(
|
||||
' --memory <size> Memory limit (e.g., "512MB", "2GB")',
|
||||
);
|
||||
console.log(' --cwd <path> Working directory');
|
||||
console.log(
|
||||
' --watch Watch for file changes and restart',
|
||||
);
|
||||
console.log(' --watch-paths <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 <id>');
|
||||
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}`);
|
||||
|
@@ -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<void> => {
|
||||
registerDefaultCommand(smartcliInstance);
|
||||
|
||||
// Process commands
|
||||
registerAddCommand(smartcliInstance);
|
||||
registerStartCommand(smartcliInstance);
|
||||
registerStopCommand(smartcliInstance);
|
||||
registerRestartCommand(smartcliInstance);
|
||||
|
@@ -17,13 +17,15 @@ 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({
|
||||
smartcli.addCommand(singleName).subscribe({
|
||||
next: async (argv: CliArguments) => {
|
||||
// Early preflight for better UX
|
||||
const ok = await ensureDaemonOrHint(requireDaemon, actionLabel);
|
||||
@@ -57,13 +59,14 @@ export function registerIpcCommand(
|
||||
error: (err) => {
|
||||
// Fallback error path (should be rare with try/catch in next)
|
||||
console.error(
|
||||
`Unexpected error in command "${name}":`,
|
||||
`Unexpected error in command "${singleName}":`,
|
||||
unknownError(err),
|
||||
);
|
||||
process.exit(1);
|
||||
},
|
||||
complete: () => {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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<IProcessConfig, 'id'> & { id?: string }): Promise<string> {
|
||||
// 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
|
||||
*/
|
||||
|
@@ -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'>) => {
|
||||
|
@@ -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<IProcessConfig, 'id'> & { 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 };
|
||||
|
Reference in New Issue
Block a user