cligr 1.0.7 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/worktrees/agent-ac25cfb2/.claude/settings.local.json +30 -0
- package/.claude/worktrees/agent-ac25cfb2/README.md +65 -0
- package/.claude/worktrees/agent-ac25cfb2/docs/plans/2026-02-13-named-params-support.md +391 -0
- package/.claude/worktrees/agent-ac25cfb2/docs/plans/2026-02-25-named-items-design.md +164 -0
- package/.claude/worktrees/agent-ac25cfb2/docs/plans/2026-02-25-named-items-implementation.md +460 -0
- package/.claude/worktrees/agent-ac25cfb2/package-lock.json +554 -0
- package/.claude/worktrees/agent-ac25cfb2/package.json +27 -0
- package/.claude/worktrees/agent-ac25cfb2/scripts/build.js +20 -0
- package/.claude/worktrees/agent-ac25cfb2/scripts/test.js +168 -0
- package/.claude/worktrees/agent-ac25cfb2/src/commands/config.ts +121 -0
- package/.claude/worktrees/agent-ac25cfb2/src/commands/groups.ts +68 -0
- package/.claude/worktrees/agent-ac25cfb2/src/commands/ls.ts +25 -0
- package/.claude/worktrees/agent-ac25cfb2/src/commands/up.ts +49 -0
- package/.claude/worktrees/agent-ac25cfb2/src/config/loader.ts +148 -0
- package/.claude/worktrees/agent-ac25cfb2/src/config/types.ts +26 -0
- package/.claude/worktrees/agent-ac25cfb2/src/index.ts +97 -0
- package/.claude/worktrees/agent-ac25cfb2/src/process/manager.ts +270 -0
- package/.claude/worktrees/agent-ac25cfb2/src/process/pid-store.ts +203 -0
- package/.claude/worktrees/agent-ac25cfb2/src/process/template.ts +87 -0
- package/.claude/worktrees/agent-ac25cfb2/tests/integration/blocking-processes-fixed.test.ts +255 -0
- package/.claude/worktrees/agent-ac25cfb2/tests/integration/blocking-processes.test.ts +497 -0
- package/.claude/worktrees/agent-ac25cfb2/tests/integration/commands.test.ts +648 -0
- package/.claude/worktrees/agent-ac25cfb2/tests/integration/config-loader.test.ts +426 -0
- package/.claude/worktrees/agent-ac25cfb2/tests/integration/process-manager.test.ts +394 -0
- package/.claude/worktrees/agent-ac25cfb2/tests/integration/template-expander.test.ts +454 -0
- package/.claude/worktrees/agent-ac25cfb2/tsconfig.json +15 -0
- package/.claude/worktrees/agent-ac25cfb2/usage.md +9 -0
- package/dist/index.js +82 -25
- package/docs/superpowers/plans/2026-04-13-improve-web-ui-console.md +256 -0
- package/docs/superpowers/specs/2026-04-13-improve-web-ui-console-design.md +38 -0
- package/package.json +1 -1
- package/src/commands/serve.ts +64 -7
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { spawn, ChildProcess } from 'child_process';
|
|
2
|
+
import type { GroupConfig, ProcessItem } from '../config/types.js';
|
|
3
|
+
import { PidStore, type PidEntry } from './pid-store.js';
|
|
4
|
+
|
|
5
|
+
export type ProcessStatus = 'running' | 'stopped' | 'failed';
|
|
6
|
+
|
|
7
|
+
export class ManagedProcess {
|
|
8
|
+
constructor(
|
|
9
|
+
public item: ProcessItem,
|
|
10
|
+
public process: ChildProcess,
|
|
11
|
+
public status: ProcessStatus = 'running'
|
|
12
|
+
) {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ProcessManager {
|
|
16
|
+
private groups = new Map<string, ManagedProcess[]>();
|
|
17
|
+
private restartTimestamps = new Map<string, number[]>();
|
|
18
|
+
private readonly maxRestarts = 3;
|
|
19
|
+
private readonly restartWindow = 10000; // 10 seconds
|
|
20
|
+
private readonly pidStore = new PidStore();
|
|
21
|
+
|
|
22
|
+
spawnGroup(groupName: string, items: ProcessItem[], restartPolicy: GroupConfig['restart']): void {
|
|
23
|
+
if (this.groups.has(groupName)) {
|
|
24
|
+
throw new Error(`Group ${groupName} is already running`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const processes: ManagedProcess[] = [];
|
|
28
|
+
|
|
29
|
+
for (const item of items) {
|
|
30
|
+
const proc = this.spawnProcess(item, groupName, restartPolicy);
|
|
31
|
+
processes.push(new ManagedProcess(item, proc));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.groups.set(groupName, processes);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private spawnProcess(item: ProcessItem, groupName: string, restartPolicy: GroupConfig['restart']): ChildProcess {
|
|
38
|
+
// Parse command into executable and args, handling quoted strings
|
|
39
|
+
const { cmd, args } = this.parseCommand(item.fullCmd);
|
|
40
|
+
|
|
41
|
+
const proc = spawn(cmd, args, {
|
|
42
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
43
|
+
// On Windows, don't use shell to avoid PID mismatch
|
|
44
|
+
// The shell's PID would be stored instead of the actual process
|
|
45
|
+
// For commands that need shell, user should use cmd /c prefix
|
|
46
|
+
shell: false,
|
|
47
|
+
windowsHide: true // Hide console window on Windows
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Write PID file when process is spawned
|
|
51
|
+
// First, clean up any stale PID file for this process
|
|
52
|
+
// This prevents issues with leftover PIDs from previous crashes
|
|
53
|
+
this.pidStore.deletePid(groupName, item.name).catch(() => {});
|
|
54
|
+
|
|
55
|
+
if (proc.pid) {
|
|
56
|
+
const pidEntry: PidEntry = {
|
|
57
|
+
pid: proc.pid,
|
|
58
|
+
groupName,
|
|
59
|
+
itemName: item.name,
|
|
60
|
+
startTime: Date.now(),
|
|
61
|
+
restartPolicy,
|
|
62
|
+
fullCmd: item.fullCmd
|
|
63
|
+
};
|
|
64
|
+
this.pidStore.writePid(pidEntry).catch(err => {
|
|
65
|
+
console.error(`[${item.name}] Failed to write PID file:`, err);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Prefix output with item name
|
|
70
|
+
if (proc.stdout) {
|
|
71
|
+
proc.stdout.on('data', (data) => {
|
|
72
|
+
process.stdout.write(`[${item.name}] ${data}`);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (proc.stderr) {
|
|
77
|
+
proc.stderr.on('data', (data) => {
|
|
78
|
+
process.stderr.write(`[${item.name}] ${data}`);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Handle exit and restart
|
|
83
|
+
proc.on('exit', (code, signal) => {
|
|
84
|
+
this.handleExit(groupName, item, restartPolicy, code, signal);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return proc;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private parseCommand(fullCmd: string): { cmd: string; args: string[] } {
|
|
91
|
+
// Handle quoted strings for Windows paths with spaces
|
|
92
|
+
const args: string[] = [];
|
|
93
|
+
let current = '';
|
|
94
|
+
let inQuote = false;
|
|
95
|
+
let quoteChar = '';
|
|
96
|
+
|
|
97
|
+
for (let i = 0; i < fullCmd.length; i++) {
|
|
98
|
+
const char = fullCmd[i];
|
|
99
|
+
const nextChar = fullCmd[i + 1];
|
|
100
|
+
|
|
101
|
+
if ((char === '"' || char === "'") && !inQuote) {
|
|
102
|
+
inQuote = true;
|
|
103
|
+
quoteChar = char;
|
|
104
|
+
} else if (char === quoteChar && inQuote) {
|
|
105
|
+
inQuote = false;
|
|
106
|
+
quoteChar = '';
|
|
107
|
+
} else if (char === ' ' && !inQuote) {
|
|
108
|
+
if (current) {
|
|
109
|
+
args.push(current);
|
|
110
|
+
current = '';
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
current += char;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (current) {
|
|
118
|
+
args.push(current);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { cmd: args[0] || '', args: args.slice(1) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private handleExit(groupName: string, item: ProcessItem, restartPolicy: GroupConfig['restart'], code: number | null, signal: NodeJS.Signals | null): void {
|
|
125
|
+
// Check if killed by cligr (don't restart if unless-stopped)
|
|
126
|
+
// SIGTERM works on both Unix and Windows in Node.js
|
|
127
|
+
if (restartPolicy === 'unless-stopped' && signal === 'SIGTERM') {
|
|
128
|
+
// Clean up PID file when killed by cligr
|
|
129
|
+
this.pidStore.deletePid(groupName, item.name).catch(() => {});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check restart policy
|
|
134
|
+
if (restartPolicy === 'no') {
|
|
135
|
+
// Clean up PID file when not restarting
|
|
136
|
+
this.pidStore.deletePid(groupName, item.name).catch(() => {});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check for crash loop (within the restart window)
|
|
141
|
+
const key = `${groupName}-${item.name}`;
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
const timestamps = this.restartTimestamps.get(key) || [];
|
|
144
|
+
|
|
145
|
+
// Filter out timestamps outside the restart window
|
|
146
|
+
const recentTimestamps = timestamps.filter(ts => now - ts < this.restartWindow);
|
|
147
|
+
recentTimestamps.push(now);
|
|
148
|
+
this.restartTimestamps.set(key, recentTimestamps);
|
|
149
|
+
|
|
150
|
+
if (recentTimestamps.length > this.maxRestarts) {
|
|
151
|
+
console.error(`[${item.name}] Crash loop detected. Stopping restarts.`);
|
|
152
|
+
// Clean up PID file when stopping due to crash loop
|
|
153
|
+
this.pidStore.deletePid(groupName, item.name).catch(() => {});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Restart after delay
|
|
158
|
+
setTimeout(() => {
|
|
159
|
+
console.log(`[${item.name}] Restarting... (exit code: ${code})`);
|
|
160
|
+
const newProc = this.spawnProcess(item, groupName, restartPolicy);
|
|
161
|
+
|
|
162
|
+
// Update the ManagedProcess in the groups Map with the new process handle
|
|
163
|
+
const processes = this.groups.get(groupName);
|
|
164
|
+
if (processes) {
|
|
165
|
+
const managedProc = processes.find(mp => mp.item.name === item.name);
|
|
166
|
+
if (managedProc) {
|
|
167
|
+
managedProc.process = newProc;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}, 1000);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
killGroup(groupName: string): Promise<void> {
|
|
174
|
+
const processes = this.groups.get(groupName);
|
|
175
|
+
if (!processes) return Promise.resolve();
|
|
176
|
+
|
|
177
|
+
const killPromises = processes.map(mp => this.killProcess(mp.process));
|
|
178
|
+
|
|
179
|
+
this.groups.delete(groupName);
|
|
180
|
+
|
|
181
|
+
// Clean up PID files after killing
|
|
182
|
+
return Promise.all(killPromises).then(async () => {
|
|
183
|
+
await this.pidStore.deleteGroupPids(groupName);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private killPid(pid: number): Promise<void> {
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
try {
|
|
190
|
+
// First try SIGTERM for graceful shutdown
|
|
191
|
+
process.kill(pid, 'SIGTERM');
|
|
192
|
+
|
|
193
|
+
// Force kill with SIGKILL after 5 seconds if still running
|
|
194
|
+
const timeout = setTimeout(() => {
|
|
195
|
+
try {
|
|
196
|
+
process.kill(pid, 'SIGKILL');
|
|
197
|
+
} catch {
|
|
198
|
+
// Process might have already exited
|
|
199
|
+
}
|
|
200
|
+
}, 5000);
|
|
201
|
+
|
|
202
|
+
// Poll for process exit
|
|
203
|
+
const checkInterval = setInterval(() => {
|
|
204
|
+
if (!this.pidStore.isPidRunning(pid)) {
|
|
205
|
+
clearTimeout(timeout);
|
|
206
|
+
clearInterval(checkInterval);
|
|
207
|
+
resolve();
|
|
208
|
+
}
|
|
209
|
+
}, 100);
|
|
210
|
+
|
|
211
|
+
// If already dead, resolve quickly
|
|
212
|
+
if (!this.pidStore.isPidRunning(pid)) {
|
|
213
|
+
clearTimeout(timeout);
|
|
214
|
+
clearInterval(checkInterval);
|
|
215
|
+
resolve();
|
|
216
|
+
}
|
|
217
|
+
} catch (err) {
|
|
218
|
+
reject(err);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private killProcess(proc: ChildProcess): Promise<void> {
|
|
224
|
+
return new Promise((resolve) => {
|
|
225
|
+
// First try SIGTERM for graceful shutdown
|
|
226
|
+
proc.kill('SIGTERM');
|
|
227
|
+
|
|
228
|
+
// Force kill with SIGKILL after 5 seconds if still running
|
|
229
|
+
const timeout = setTimeout(() => {
|
|
230
|
+
if (!proc.killed) {
|
|
231
|
+
proc.kill('SIGKILL');
|
|
232
|
+
}
|
|
233
|
+
}, 5000);
|
|
234
|
+
|
|
235
|
+
proc.on('exit', () => {
|
|
236
|
+
clearTimeout(timeout);
|
|
237
|
+
resolve();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// If already dead, resolve immediately
|
|
241
|
+
if (proc.killed || proc.exitCode !== null) {
|
|
242
|
+
clearTimeout(timeout);
|
|
243
|
+
resolve();
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
killAll(): Promise<void> {
|
|
249
|
+
const killPromises: Promise<void>[] = [];
|
|
250
|
+
for (const groupName of this.groups.keys()) {
|
|
251
|
+
killPromises.push(this.killGroup(groupName));
|
|
252
|
+
}
|
|
253
|
+
return Promise.all(killPromises).then(() => {});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
getGroupStatus(groupName: string): ProcessStatus[] {
|
|
257
|
+
const processes = this.groups.get(groupName);
|
|
258
|
+
if (!processes) return [];
|
|
259
|
+
|
|
260
|
+
return processes.map(mp => mp.status);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
isGroupRunning(groupName: string): boolean {
|
|
264
|
+
return this.groups.has(groupName);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
getRunningGroups(): string[] {
|
|
268
|
+
return Array.from(this.groups.keys());
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
export interface PidEntry {
|
|
6
|
+
pid: number;
|
|
7
|
+
groupName: string;
|
|
8
|
+
itemName: string;
|
|
9
|
+
startTime: number;
|
|
10
|
+
restartPolicy: 'yes' | 'no' | 'unless-stopped';
|
|
11
|
+
fullCmd: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class PidStore {
|
|
15
|
+
private readonly pidsDir: string;
|
|
16
|
+
|
|
17
|
+
constructor() {
|
|
18
|
+
this.pidsDir = path.join(os.homedir(), '.cligr', 'pids');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Initialize the PID directory
|
|
23
|
+
*/
|
|
24
|
+
async ensureDir(): Promise<void> {
|
|
25
|
+
try {
|
|
26
|
+
await fs.mkdir(this.pidsDir, { recursive: true });
|
|
27
|
+
} catch (err) {
|
|
28
|
+
// Ignore if already exists
|
|
29
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sanitize item name for use in file path
|
|
37
|
+
* Replaces characters invalid on Windows (like colons in paths)
|
|
38
|
+
*/
|
|
39
|
+
private sanitizeItemName(itemName: string): string {
|
|
40
|
+
// Replace characters that are invalid in Windows filenames
|
|
41
|
+
// < > : " / \ | ? *
|
|
42
|
+
return itemName.replace(/[<>:"/\\|?*]/g, '_');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get the PID file path for a specific group and item
|
|
47
|
+
*/
|
|
48
|
+
private getPidFilePath(groupName: string, itemName: string): string {
|
|
49
|
+
const sanitizedName = this.sanitizeItemName(itemName);
|
|
50
|
+
return path.join(this.pidsDir, `${groupName}_${sanitizedName}.pid`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Write a PID file with metadata
|
|
55
|
+
*/
|
|
56
|
+
async writePid(entry: PidEntry): Promise<void> {
|
|
57
|
+
await this.ensureDir();
|
|
58
|
+
const filePath = this.getPidFilePath(entry.groupName, entry.itemName);
|
|
59
|
+
await fs.writeFile(filePath, JSON.stringify(entry, null, 2), 'utf-8');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read all PID entries for a specific group
|
|
64
|
+
*/
|
|
65
|
+
async readPidsByGroup(groupName: string): Promise<PidEntry[]> {
|
|
66
|
+
await this.ensureDir();
|
|
67
|
+
const entries: PidEntry[] = [];
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const files = await fs.readdir(this.pidsDir);
|
|
71
|
+
const prefix = `${groupName}_`;
|
|
72
|
+
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
if (file.startsWith(prefix) && file.endsWith('.pid')) {
|
|
75
|
+
try {
|
|
76
|
+
const content = await fs.readFile(path.join(this.pidsDir, file), 'utf-8');
|
|
77
|
+
entries.push(JSON.parse(content) as PidEntry);
|
|
78
|
+
} catch {
|
|
79
|
+
// Skip invalid files
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Directory doesn't exist or can't be read
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return entries;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read all PID entries
|
|
94
|
+
*/
|
|
95
|
+
async readAllPids(): Promise<PidEntry[]> {
|
|
96
|
+
await this.ensureDir();
|
|
97
|
+
const entries: PidEntry[] = [];
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const files = await fs.readdir(this.pidsDir);
|
|
101
|
+
|
|
102
|
+
for (const file of files) {
|
|
103
|
+
if (file.endsWith('.pid')) {
|
|
104
|
+
try {
|
|
105
|
+
const content = await fs.readFile(path.join(this.pidsDir, file), 'utf-8');
|
|
106
|
+
entries.push(JSON.parse(content) as PidEntry);
|
|
107
|
+
} catch {
|
|
108
|
+
// Skip invalid files
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// Directory doesn't exist or can't be read
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return entries;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Delete a specific PID file
|
|
123
|
+
*/
|
|
124
|
+
async deletePid(groupName: string, itemName: string): Promise<void> {
|
|
125
|
+
const filePath = this.getPidFilePath(groupName, itemName);
|
|
126
|
+
try {
|
|
127
|
+
await fs.unlink(filePath);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
// Ignore if file doesn't exist
|
|
130
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Delete all PID files for a group
|
|
138
|
+
*/
|
|
139
|
+
async deleteGroupPids(groupName: string): Promise<void> {
|
|
140
|
+
const entries = await this.readPidsByGroup(groupName);
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
await this.deletePid(entry.groupName, entry.itemName);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check if a PID is currently running (cross-platform)
|
|
148
|
+
*/
|
|
149
|
+
isPidRunning(pid: number): boolean {
|
|
150
|
+
try {
|
|
151
|
+
// Sending signal 0 checks if process exists without killing it
|
|
152
|
+
process.kill(pid, 0);
|
|
153
|
+
return true;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if a PID entry is still valid and running.
|
|
161
|
+
* This helps prevent killing wrong processes if PID is reused by the OS.
|
|
162
|
+
* A PID is considered valid if it's running AND started recently (within last 5 minutes).
|
|
163
|
+
*/
|
|
164
|
+
isPidEntryValid(entry: PidEntry): boolean {
|
|
165
|
+
if (!this.isPidRunning(entry.pid)) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check if the process start time is recent (within 5 minutes)
|
|
170
|
+
// This prevents killing a wrong process if PID was reused
|
|
171
|
+
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
|
|
172
|
+
return entry.startTime > fiveMinutesAgo;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Remove PID files for processes that are no longer running
|
|
177
|
+
* Returns the list of stale entries that were removed
|
|
178
|
+
*/
|
|
179
|
+
async cleanupStalePids(): Promise<PidEntry[]> {
|
|
180
|
+
const allEntries = await this.readAllPids();
|
|
181
|
+
const staleEntries: PidEntry[] = [];
|
|
182
|
+
|
|
183
|
+
for (const entry of allEntries) {
|
|
184
|
+
// Check if PID is no longer running OR if entry is too old (> 5 minutes)
|
|
185
|
+
// This helps prevent PID reuse issues
|
|
186
|
+
if (!this.isPidEntryValid(entry)) {
|
|
187
|
+
staleEntries.push(entry);
|
|
188
|
+
await this.deletePid(entry.groupName, entry.itemName);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return staleEntries;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get all unique group names that have PID files
|
|
197
|
+
*/
|
|
198
|
+
async getRunningGroups(): Promise<string[]> {
|
|
199
|
+
const allEntries = await this.readAllPids();
|
|
200
|
+
const groups = new Set(allEntries.map(e => e.groupName));
|
|
201
|
+
return Array.from(groups);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ProcessItem, ItemEntry } from '../config/types.js';
|
|
2
|
+
|
|
3
|
+
export class TemplateExpander {
|
|
4
|
+
/**
|
|
5
|
+
* Replaces named params in template ($name, $env, etc.)
|
|
6
|
+
* @param template - Command template with $paramName placeholders
|
|
7
|
+
* @param params - Key-value pairs for substitution
|
|
8
|
+
* @returns Template with named params replaced
|
|
9
|
+
*/
|
|
10
|
+
private static expandNamedParams(template: string, params: Record<string, string>): string {
|
|
11
|
+
let result = template;
|
|
12
|
+
for (const [key, value] of Object.entries(params)) {
|
|
13
|
+
const placeholder = `$${key}`;
|
|
14
|
+
result = result.replaceAll(placeholder, value);
|
|
15
|
+
}
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Expands a command template with item arguments
|
|
21
|
+
* @param template - Command template with $1, $2, $3 etc.
|
|
22
|
+
* @param item - ItemEntry with name and value
|
|
23
|
+
* @param index - Item index in group
|
|
24
|
+
* @param params - Optional named params for substitution ($name, $env, etc.)
|
|
25
|
+
* @returns ProcessItem with expanded command
|
|
26
|
+
*/
|
|
27
|
+
static expand(template: string, item: ItemEntry, index: number, params: Record<string, string> = {}): ProcessItem {
|
|
28
|
+
const args = item.value.split(',').map(s => s.trim());
|
|
29
|
+
|
|
30
|
+
// Use explicit name from ItemEntry
|
|
31
|
+
const name = item.name;
|
|
32
|
+
|
|
33
|
+
// Replace $1, $2, $3 etc. with args (positional params)
|
|
34
|
+
let fullCmd = template;
|
|
35
|
+
for (let i = args.length - 1; i >= 0; i--) {
|
|
36
|
+
const placeholder = `$${i + 1}`;
|
|
37
|
+
fullCmd = fullCmd.replaceAll(placeholder, args[i]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Replace named params ($name, $env, etc.) AFTER positional params
|
|
41
|
+
fullCmd = this.expandNamedParams(fullCmd, params);
|
|
42
|
+
|
|
43
|
+
return { name, args, fullCmd };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parses item string into command
|
|
48
|
+
* @param tool - Tool name or executable
|
|
49
|
+
* @param toolTemplate - Template from tools config (if registered tool)
|
|
50
|
+
* @param item - ItemEntry with name and value
|
|
51
|
+
* @param index - Item index in group
|
|
52
|
+
* @param params - Optional named params for substitution
|
|
53
|
+
*/
|
|
54
|
+
static parseItem(
|
|
55
|
+
tool: string | null,
|
|
56
|
+
toolTemplate: string | null,
|
|
57
|
+
item: ItemEntry,
|
|
58
|
+
index: number,
|
|
59
|
+
params: Record<string, string> = {}
|
|
60
|
+
): ProcessItem {
|
|
61
|
+
if (toolTemplate) {
|
|
62
|
+
// Use registered tool template
|
|
63
|
+
const result = this.expand(toolTemplate, item, index, params);
|
|
64
|
+
|
|
65
|
+
// If there are more args than placeholders in the template, append them
|
|
66
|
+
const placeholdersInTemplate = (toolTemplate.match(/\$\d+/g) || []);
|
|
67
|
+
let maxPlaceholder = 0;
|
|
68
|
+
for (const p of placeholdersInTemplate) {
|
|
69
|
+
const num = parseInt(p.substring(1), 10);
|
|
70
|
+
if (num > maxPlaceholder) maxPlaceholder = num;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (maxPlaceholder > 0 && result.args.length > maxPlaceholder) {
|
|
74
|
+
const remainingArgs = result.args.slice(maxPlaceholder);
|
|
75
|
+
result.fullCmd = `${result.fullCmd} ${remainingArgs.join(' ')}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return result;
|
|
79
|
+
} else {
|
|
80
|
+
// Direct executable - use tool as command prefix
|
|
81
|
+
const args = item.value.split(',').map(s => s.trim());
|
|
82
|
+
const name = item.name;
|
|
83
|
+
const fullCmd = tool ? `${tool} ${item.value}` : item.value;
|
|
84
|
+
return { name, args, fullCmd };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|