cligr 1.0.0 → 1.0.2
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/dist/commands/config.js +102 -0
- package/dist/commands/down.js +26 -0
- package/dist/commands/groups.js +43 -0
- package/dist/commands/ls.js +23 -0
- package/dist/commands/up.js +39 -0
- package/dist/config/loader.js +82 -0
- package/dist/config/types.js +0 -0
- package/dist/index.js +35 -35
- package/dist/process/manager.js +226 -0
- package/dist/process/pid-store.js +141 -0
- package/dist/process/template.js +53 -0
- package/package.json +2 -1
- package/src/commands/down.ts +28 -3
- package/src/commands/groups.ts +1 -1
- package/src/commands/up.ts +5 -0
- package/src/process/manager.ts +102 -2
- package/src/process/pid-store.ts +192 -0
- package/tests/integration/commands.test.ts +3 -3
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { PidStore } from "./pid-store.js";
|
|
3
|
+
class ManagedProcess {
|
|
4
|
+
constructor(item, process2, status = "running") {
|
|
5
|
+
this.item = item;
|
|
6
|
+
this.process = process2;
|
|
7
|
+
this.status = status;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
class ProcessManager {
|
|
11
|
+
groups = /* @__PURE__ */ new Map();
|
|
12
|
+
restartTimestamps = /* @__PURE__ */ new Map();
|
|
13
|
+
maxRestarts = 3;
|
|
14
|
+
restartWindow = 1e4;
|
|
15
|
+
// 10 seconds
|
|
16
|
+
pidStore = new PidStore();
|
|
17
|
+
spawnGroup(groupName, items, restartPolicy) {
|
|
18
|
+
if (this.groups.has(groupName)) {
|
|
19
|
+
throw new Error(`Group ${groupName} is already running`);
|
|
20
|
+
}
|
|
21
|
+
const processes = [];
|
|
22
|
+
for (const item of items) {
|
|
23
|
+
const proc = this.spawnProcess(item, groupName, restartPolicy);
|
|
24
|
+
processes.push(new ManagedProcess(item, proc));
|
|
25
|
+
}
|
|
26
|
+
this.groups.set(groupName, processes);
|
|
27
|
+
}
|
|
28
|
+
spawnProcess(item, groupName, restartPolicy) {
|
|
29
|
+
const { cmd, args } = this.parseCommand(item.fullCmd);
|
|
30
|
+
const proc = spawn(cmd, args, {
|
|
31
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
32
|
+
shell: process.platform === "win32"
|
|
33
|
+
// Use shell on Windows for better path handling
|
|
34
|
+
});
|
|
35
|
+
if (proc.pid) {
|
|
36
|
+
const pidEntry = {
|
|
37
|
+
pid: proc.pid,
|
|
38
|
+
groupName,
|
|
39
|
+
itemName: item.name,
|
|
40
|
+
startTime: Date.now(),
|
|
41
|
+
restartPolicy,
|
|
42
|
+
fullCmd: item.fullCmd
|
|
43
|
+
};
|
|
44
|
+
this.pidStore.writePid(pidEntry).catch((err) => {
|
|
45
|
+
console.error(`[${item.name}] Failed to write PID file:`, err);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (proc.stdout) {
|
|
49
|
+
proc.stdout.on("data", (data) => {
|
|
50
|
+
process.stdout.write(`[${item.name}] ${data}`);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (proc.stderr) {
|
|
54
|
+
proc.stderr.on("data", (data) => {
|
|
55
|
+
process.stderr.write(`[${item.name}] ${data}`);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
proc.on("exit", (code, signal) => {
|
|
59
|
+
this.handleExit(groupName, item, restartPolicy, code, signal);
|
|
60
|
+
});
|
|
61
|
+
return proc;
|
|
62
|
+
}
|
|
63
|
+
parseCommand(fullCmd) {
|
|
64
|
+
const args = [];
|
|
65
|
+
let current = "";
|
|
66
|
+
let inQuote = false;
|
|
67
|
+
let quoteChar = "";
|
|
68
|
+
for (let i = 0; i < fullCmd.length; i++) {
|
|
69
|
+
const char = fullCmd[i];
|
|
70
|
+
const nextChar = fullCmd[i + 1];
|
|
71
|
+
if ((char === '"' || char === "'") && !inQuote) {
|
|
72
|
+
inQuote = true;
|
|
73
|
+
quoteChar = char;
|
|
74
|
+
} else if (char === quoteChar && inQuote) {
|
|
75
|
+
inQuote = false;
|
|
76
|
+
quoteChar = "";
|
|
77
|
+
} else if (char === " " && !inQuote) {
|
|
78
|
+
if (current) {
|
|
79
|
+
args.push(current);
|
|
80
|
+
current = "";
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
current += char;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (current) {
|
|
87
|
+
args.push(current);
|
|
88
|
+
}
|
|
89
|
+
return { cmd: args[0] || "", args: args.slice(1) };
|
|
90
|
+
}
|
|
91
|
+
handleExit(groupName, item, restartPolicy, code, signal) {
|
|
92
|
+
if (restartPolicy === "unless-stopped" && signal === "SIGTERM") {
|
|
93
|
+
this.pidStore.deletePid(groupName, item.name).catch(() => {
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (restartPolicy === "no") {
|
|
98
|
+
this.pidStore.deletePid(groupName, item.name).catch(() => {
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const key = `${groupName}-${item.name}`;
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
const timestamps = this.restartTimestamps.get(key) || [];
|
|
105
|
+
const recentTimestamps = timestamps.filter((ts) => now - ts < this.restartWindow);
|
|
106
|
+
recentTimestamps.push(now);
|
|
107
|
+
this.restartTimestamps.set(key, recentTimestamps);
|
|
108
|
+
if (recentTimestamps.length > this.maxRestarts) {
|
|
109
|
+
console.error(`[${item.name}] Crash loop detected. Stopping restarts.`);
|
|
110
|
+
this.pidStore.deletePid(groupName, item.name).catch(() => {
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
setTimeout(() => {
|
|
115
|
+
console.log(`[${item.name}] Restarting... (exit code: ${code})`);
|
|
116
|
+
const newProc = this.spawnProcess(item, groupName, restartPolicy);
|
|
117
|
+
const processes = this.groups.get(groupName);
|
|
118
|
+
if (processes) {
|
|
119
|
+
const managedProc = processes.find((mp) => mp.item.name === item.name);
|
|
120
|
+
if (managedProc) {
|
|
121
|
+
managedProc.process = newProc;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}, 1e3);
|
|
125
|
+
}
|
|
126
|
+
killGroup(groupName) {
|
|
127
|
+
const processes = this.groups.get(groupName);
|
|
128
|
+
if (!processes) return Promise.resolve();
|
|
129
|
+
const killPromises = processes.map((mp) => this.killProcess(mp.process));
|
|
130
|
+
this.groups.delete(groupName);
|
|
131
|
+
return Promise.all(killPromises).then(async () => {
|
|
132
|
+
await this.pidStore.deleteGroupPids(groupName);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Kill processes for a group by reading from PID store.
|
|
137
|
+
* This is used by the 'down' command to stop processes that were
|
|
138
|
+
* started by a previous cligr instance.
|
|
139
|
+
*/
|
|
140
|
+
async killGroupByPid(groupName) {
|
|
141
|
+
const entries = await this.pidStore.readPidsByGroup(groupName);
|
|
142
|
+
const result = { killed: 0, notRunning: 0, errors: [] };
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (this.pidStore.isPidRunning(entry.pid)) {
|
|
145
|
+
try {
|
|
146
|
+
await this.killPid(entry.pid);
|
|
147
|
+
result.killed++;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
result.errors.push(`[${entry.itemName}] ${err}`);
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
result.notRunning++;
|
|
153
|
+
}
|
|
154
|
+
await this.pidStore.deletePid(entry.groupName, entry.itemName);
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
killPid(pid) {
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
try {
|
|
161
|
+
process.kill(pid, "SIGTERM");
|
|
162
|
+
const timeout = setTimeout(() => {
|
|
163
|
+
try {
|
|
164
|
+
process.kill(pid, "SIGKILL");
|
|
165
|
+
} catch {
|
|
166
|
+
}
|
|
167
|
+
}, 5e3);
|
|
168
|
+
const checkInterval = setInterval(() => {
|
|
169
|
+
if (!this.pidStore.isPidRunning(pid)) {
|
|
170
|
+
clearTimeout(timeout);
|
|
171
|
+
clearInterval(checkInterval);
|
|
172
|
+
resolve();
|
|
173
|
+
}
|
|
174
|
+
}, 100);
|
|
175
|
+
if (!this.pidStore.isPidRunning(pid)) {
|
|
176
|
+
clearTimeout(timeout);
|
|
177
|
+
clearInterval(checkInterval);
|
|
178
|
+
resolve();
|
|
179
|
+
}
|
|
180
|
+
} catch (err) {
|
|
181
|
+
reject(err);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
killProcess(proc) {
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
proc.kill("SIGTERM");
|
|
188
|
+
const timeout = setTimeout(() => {
|
|
189
|
+
if (!proc.killed) {
|
|
190
|
+
proc.kill("SIGKILL");
|
|
191
|
+
}
|
|
192
|
+
}, 5e3);
|
|
193
|
+
proc.on("exit", () => {
|
|
194
|
+
clearTimeout(timeout);
|
|
195
|
+
resolve();
|
|
196
|
+
});
|
|
197
|
+
if (proc.killed || proc.exitCode !== null) {
|
|
198
|
+
clearTimeout(timeout);
|
|
199
|
+
resolve();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
killAll() {
|
|
204
|
+
const killPromises = [];
|
|
205
|
+
for (const groupName of this.groups.keys()) {
|
|
206
|
+
killPromises.push(this.killGroup(groupName));
|
|
207
|
+
}
|
|
208
|
+
return Promise.all(killPromises).then(() => {
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
getGroupStatus(groupName) {
|
|
212
|
+
const processes = this.groups.get(groupName);
|
|
213
|
+
if (!processes) return [];
|
|
214
|
+
return processes.map((mp) => mp.status);
|
|
215
|
+
}
|
|
216
|
+
isGroupRunning(groupName) {
|
|
217
|
+
return this.groups.has(groupName);
|
|
218
|
+
}
|
|
219
|
+
getRunningGroups() {
|
|
220
|
+
return Array.from(this.groups.keys());
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
export {
|
|
224
|
+
ManagedProcess,
|
|
225
|
+
ProcessManager
|
|
226
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
class PidStore {
|
|
5
|
+
pidsDir;
|
|
6
|
+
constructor() {
|
|
7
|
+
this.pidsDir = path.join(os.homedir(), ".cligr", "pids");
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Initialize the PID directory
|
|
11
|
+
*/
|
|
12
|
+
async ensureDir() {
|
|
13
|
+
try {
|
|
14
|
+
await fs.mkdir(this.pidsDir, { recursive: true });
|
|
15
|
+
} catch (err) {
|
|
16
|
+
if (err.code !== "EEXIST") {
|
|
17
|
+
throw err;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Get the PID file path for a specific group and item
|
|
23
|
+
*/
|
|
24
|
+
getPidFilePath(groupName, itemName) {
|
|
25
|
+
return path.join(this.pidsDir, `${groupName}_${itemName}.pid`);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Write a PID file with metadata
|
|
29
|
+
*/
|
|
30
|
+
async writePid(entry) {
|
|
31
|
+
await this.ensureDir();
|
|
32
|
+
const filePath = this.getPidFilePath(entry.groupName, entry.itemName);
|
|
33
|
+
await fs.writeFile(filePath, JSON.stringify(entry, null, 2), "utf-8");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Read all PID entries for a specific group
|
|
37
|
+
*/
|
|
38
|
+
async readPidsByGroup(groupName) {
|
|
39
|
+
await this.ensureDir();
|
|
40
|
+
const entries = [];
|
|
41
|
+
try {
|
|
42
|
+
const files = await fs.readdir(this.pidsDir);
|
|
43
|
+
const prefix = `${groupName}_`;
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
if (file.startsWith(prefix) && file.endsWith(".pid")) {
|
|
46
|
+
try {
|
|
47
|
+
const content = await fs.readFile(path.join(this.pidsDir, file), "utf-8");
|
|
48
|
+
entries.push(JSON.parse(content));
|
|
49
|
+
} catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
return entries;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Read all PID entries
|
|
61
|
+
*/
|
|
62
|
+
async readAllPids() {
|
|
63
|
+
await this.ensureDir();
|
|
64
|
+
const entries = [];
|
|
65
|
+
try {
|
|
66
|
+
const files = await fs.readdir(this.pidsDir);
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
if (file.endsWith(".pid")) {
|
|
69
|
+
try {
|
|
70
|
+
const content = await fs.readFile(path.join(this.pidsDir, file), "utf-8");
|
|
71
|
+
entries.push(JSON.parse(content));
|
|
72
|
+
} catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
return entries;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Delete a specific PID file
|
|
84
|
+
*/
|
|
85
|
+
async deletePid(groupName, itemName) {
|
|
86
|
+
const filePath = this.getPidFilePath(groupName, itemName);
|
|
87
|
+
try {
|
|
88
|
+
await fs.unlink(filePath);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err.code !== "ENOENT") {
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Delete all PID files for a group
|
|
97
|
+
*/
|
|
98
|
+
async deleteGroupPids(groupName) {
|
|
99
|
+
const entries = await this.readPidsByGroup(groupName);
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
await this.deletePid(entry.groupName, entry.itemName);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Check if a PID is currently running (cross-platform)
|
|
106
|
+
*/
|
|
107
|
+
isPidRunning(pid) {
|
|
108
|
+
try {
|
|
109
|
+
process.kill(pid, 0);
|
|
110
|
+
return true;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Remove PID files for processes that are no longer running
|
|
117
|
+
* Returns the list of stale entries that were removed
|
|
118
|
+
*/
|
|
119
|
+
async cleanupStalePids() {
|
|
120
|
+
const allEntries = await this.readAllPids();
|
|
121
|
+
const staleEntries = [];
|
|
122
|
+
for (const entry of allEntries) {
|
|
123
|
+
if (!this.isPidRunning(entry.pid)) {
|
|
124
|
+
staleEntries.push(entry);
|
|
125
|
+
await this.deletePid(entry.groupName, entry.itemName);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return staleEntries;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Get all unique group names that have PID files
|
|
132
|
+
*/
|
|
133
|
+
async getRunningGroups() {
|
|
134
|
+
const allEntries = await this.readAllPids();
|
|
135
|
+
const groups = new Set(allEntries.map((e) => e.groupName));
|
|
136
|
+
return Array.from(groups);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export {
|
|
140
|
+
PidStore
|
|
141
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
class TemplateExpander {
|
|
2
|
+
/**
|
|
3
|
+
* Expands a command template with item arguments
|
|
4
|
+
* @param template - Command template with $1, $2, $3 etc.
|
|
5
|
+
* @param itemStr - Comma-separated item args (e.g., "service1,8080,80")
|
|
6
|
+
* @returns ProcessItem with expanded command
|
|
7
|
+
*/
|
|
8
|
+
static expand(template, itemStr, index) {
|
|
9
|
+
const args = itemStr.split(",").map((s) => s.trim());
|
|
10
|
+
const name = args[0] || `item-${index}`;
|
|
11
|
+
let fullCmd = template;
|
|
12
|
+
for (let i = args.length - 1; i >= 0; i--) {
|
|
13
|
+
const placeholder = `$${i + 1}`;
|
|
14
|
+
fullCmd = fullCmd.replaceAll(placeholder, args[i]);
|
|
15
|
+
}
|
|
16
|
+
return { name, args, fullCmd };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parses item string into command
|
|
20
|
+
* @param tool - Tool name or executable
|
|
21
|
+
* @param toolTemplate - Template from tools config (if registered tool)
|
|
22
|
+
* @param itemStr - Comma-separated args
|
|
23
|
+
* @param index - Item index in group
|
|
24
|
+
*/
|
|
25
|
+
static parseItem(tool, toolTemplate, itemStr, index) {
|
|
26
|
+
if (toolTemplate) {
|
|
27
|
+
const result = this.expand(toolTemplate, itemStr, index);
|
|
28
|
+
const placeholdersInTemplate = toolTemplate.match(/\$\d+/g) || [];
|
|
29
|
+
let maxPlaceholder = 0;
|
|
30
|
+
for (const p of placeholdersInTemplate) {
|
|
31
|
+
const num = parseInt(p.substring(1), 10);
|
|
32
|
+
if (num > maxPlaceholder) maxPlaceholder = num;
|
|
33
|
+
}
|
|
34
|
+
if (maxPlaceholder > 0 && result.args.length > maxPlaceholder) {
|
|
35
|
+
const remainingArgs = result.args.slice(maxPlaceholder);
|
|
36
|
+
result.fullCmd = `${result.fullCmd} ${remainingArgs.join(" ")}`;
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
} else {
|
|
40
|
+
const args = itemStr.split(",").map((s) => s.trim());
|
|
41
|
+
let name = args[0] || `item-${index}`;
|
|
42
|
+
if (!itemStr.includes(",") && args.length === 1) {
|
|
43
|
+
const words = args[0].split(/\s+/);
|
|
44
|
+
name = words[0] || `item-${index}`;
|
|
45
|
+
}
|
|
46
|
+
const fullCmd = tool ? `${tool} ${itemStr}` : itemStr;
|
|
47
|
+
return { name, args, fullCmd };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export {
|
|
52
|
+
TemplateExpander
|
|
53
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cligr",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cligr": "./dist/index.js"
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"typescript": "^5.7.3"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
+
"-": "^0.0.1",
|
|
24
25
|
"js-yaml": "^4.1.1"
|
|
25
26
|
}
|
|
26
27
|
}
|
package/src/commands/down.ts
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
|
+
import { ProcessManager } from '../process/manager.js';
|
|
2
|
+
|
|
1
3
|
export async function downCommand(groupName: string): Promise<number> {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
const manager = new ProcessManager();
|
|
5
|
+
|
|
6
|
+
// Check for PID files and kill processes
|
|
7
|
+
const result = await manager.killGroupByPid(groupName);
|
|
8
|
+
|
|
9
|
+
if (result.killed === 0 && result.notRunning === 0) {
|
|
10
|
+
console.log(`Group '${groupName}' is not running`);
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (result.killed > 0) {
|
|
15
|
+
console.log(`Stopped ${result.killed} process(es) for group '${groupName}'`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (result.notRunning > 0) {
|
|
19
|
+
console.log(`Cleaned up ${result.notRunning} stale PID file(s) for group '${groupName}'`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (result.errors.length > 0) {
|
|
23
|
+
console.error('Errors while stopping processes:');
|
|
24
|
+
for (const err of result.errors) {
|
|
25
|
+
console.error(` ${err}`);
|
|
26
|
+
}
|
|
27
|
+
return 1;
|
|
28
|
+
}
|
|
29
|
+
|
|
5
30
|
return 0;
|
|
6
31
|
}
|
package/src/commands/groups.ts
CHANGED
package/src/commands/up.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { ConfigLoader } from '../config/loader.js';
|
|
2
2
|
import { TemplateExpander } from '../process/template.js';
|
|
3
3
|
import { ProcessManager } from '../process/manager.js';
|
|
4
|
+
import { PidStore } from '../process/pid-store.js';
|
|
4
5
|
|
|
5
6
|
export async function upCommand(groupName: string): Promise<number> {
|
|
6
7
|
const loader = new ConfigLoader();
|
|
7
8
|
const manager = new ProcessManager();
|
|
9
|
+
const pidStore = new PidStore();
|
|
8
10
|
|
|
9
11
|
try {
|
|
12
|
+
// Clean up any stale PID files for this group on startup
|
|
13
|
+
await pidStore.cleanupStalePids();
|
|
14
|
+
|
|
10
15
|
// Load group config
|
|
11
16
|
const { config, tool, toolTemplate } = loader.getGroup(groupName);
|
|
12
17
|
|
package/src/process/manager.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn, ChildProcess } from 'child_process';
|
|
2
2
|
import type { GroupConfig, ProcessItem } from '../config/types.js';
|
|
3
|
+
import { PidStore, type PidEntry } from './pid-store.js';
|
|
3
4
|
|
|
4
5
|
export type ProcessStatus = 'running' | 'stopped' | 'failed';
|
|
5
6
|
|
|
@@ -16,6 +17,7 @@ export class ProcessManager {
|
|
|
16
17
|
private restartTimestamps = new Map<string, number[]>();
|
|
17
18
|
private readonly maxRestarts = 3;
|
|
18
19
|
private readonly restartWindow = 10000; // 10 seconds
|
|
20
|
+
private readonly pidStore = new PidStore();
|
|
19
21
|
|
|
20
22
|
spawnGroup(groupName: string, items: ProcessItem[], restartPolicy: GroupConfig['restart']): void {
|
|
21
23
|
if (this.groups.has(groupName)) {
|
|
@@ -38,9 +40,32 @@ export class ProcessManager {
|
|
|
38
40
|
|
|
39
41
|
const proc = spawn(cmd, args, {
|
|
40
42
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
41
|
-
|
|
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
|
|
42
48
|
});
|
|
43
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
|
+
|
|
44
69
|
// Prefix output with item name
|
|
45
70
|
if (proc.stdout) {
|
|
46
71
|
proc.stdout.on('data', (data) => {
|
|
@@ -100,11 +125,15 @@ export class ProcessManager {
|
|
|
100
125
|
// Check if killed by cligr (don't restart if unless-stopped)
|
|
101
126
|
// SIGTERM works on both Unix and Windows in Node.js
|
|
102
127
|
if (restartPolicy === 'unless-stopped' && signal === 'SIGTERM') {
|
|
128
|
+
// Clean up PID file when killed by cligr
|
|
129
|
+
this.pidStore.deletePid(groupName, item.name).catch(() => {});
|
|
103
130
|
return;
|
|
104
131
|
}
|
|
105
132
|
|
|
106
133
|
// Check restart policy
|
|
107
134
|
if (restartPolicy === 'no') {
|
|
135
|
+
// Clean up PID file when not restarting
|
|
136
|
+
this.pidStore.deletePid(groupName, item.name).catch(() => {});
|
|
108
137
|
return;
|
|
109
138
|
}
|
|
110
139
|
|
|
@@ -120,6 +149,8 @@ export class ProcessManager {
|
|
|
120
149
|
|
|
121
150
|
if (recentTimestamps.length > this.maxRestarts) {
|
|
122
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(() => {});
|
|
123
154
|
return;
|
|
124
155
|
}
|
|
125
156
|
|
|
@@ -146,7 +177,76 @@ export class ProcessManager {
|
|
|
146
177
|
const killPromises = processes.map(mp => this.killProcess(mp.process));
|
|
147
178
|
|
|
148
179
|
this.groups.delete(groupName);
|
|
149
|
-
|
|
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
|
+
/**
|
|
188
|
+
* Kill processes for a group by reading from PID store.
|
|
189
|
+
* This is used by the 'down' command to stop processes that were
|
|
190
|
+
* started by a previous cligr instance.
|
|
191
|
+
*/
|
|
192
|
+
async killGroupByPid(groupName: string): Promise<{ killed: number; notRunning: number; errors: string[] }> {
|
|
193
|
+
const entries = await this.pidStore.readPidsByGroup(groupName);
|
|
194
|
+
const result = { killed: 0, notRunning: 0, errors: [] as string[] };
|
|
195
|
+
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
// Check if PID entry is valid (running and recent)
|
|
198
|
+
// This prevents killing wrong processes if PID was reused by OS
|
|
199
|
+
if (this.pidStore.isPidEntryValid(entry)) {
|
|
200
|
+
try {
|
|
201
|
+
await this.killPid(entry.pid);
|
|
202
|
+
result.killed++;
|
|
203
|
+
} catch (err) {
|
|
204
|
+
result.errors.push(`[${entry.itemName}] ${err}`);
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
result.notRunning++;
|
|
208
|
+
}
|
|
209
|
+
// Clean up PID file regardless of whether process was running
|
|
210
|
+
await this.pidStore.deletePid(entry.groupName, entry.itemName);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private killPid(pid: number): Promise<void> {
|
|
217
|
+
return new Promise((resolve, reject) => {
|
|
218
|
+
try {
|
|
219
|
+
// First try SIGTERM for graceful shutdown
|
|
220
|
+
process.kill(pid, 'SIGTERM');
|
|
221
|
+
|
|
222
|
+
// Force kill with SIGKILL after 5 seconds if still running
|
|
223
|
+
const timeout = setTimeout(() => {
|
|
224
|
+
try {
|
|
225
|
+
process.kill(pid, 'SIGKILL');
|
|
226
|
+
} catch {
|
|
227
|
+
// Process might have already exited
|
|
228
|
+
}
|
|
229
|
+
}, 5000);
|
|
230
|
+
|
|
231
|
+
// Poll for process exit
|
|
232
|
+
const checkInterval = setInterval(() => {
|
|
233
|
+
if (!this.pidStore.isPidRunning(pid)) {
|
|
234
|
+
clearTimeout(timeout);
|
|
235
|
+
clearInterval(checkInterval);
|
|
236
|
+
resolve();
|
|
237
|
+
}
|
|
238
|
+
}, 100);
|
|
239
|
+
|
|
240
|
+
// If already dead, resolve quickly
|
|
241
|
+
if (!this.pidStore.isPidRunning(pid)) {
|
|
242
|
+
clearTimeout(timeout);
|
|
243
|
+
clearInterval(checkInterval);
|
|
244
|
+
resolve();
|
|
245
|
+
}
|
|
246
|
+
} catch (err) {
|
|
247
|
+
reject(err);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
150
250
|
}
|
|
151
251
|
|
|
152
252
|
private killProcess(proc: ChildProcess): Promise<void> {
|