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.
@@ -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.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
  }
@@ -1,6 +1,31 @@
1
+ import { ProcessManager } from '../process/manager.js';
2
+
1
3
  export async function downCommand(groupName: string): Promise<number> {
2
- // Note: In single-process approach, down is only useful
3
- // if we add persistent state later
4
- console.log(`Command 'down ${groupName}' - group will stop when cligr exits`);
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
  }
@@ -28,7 +28,7 @@ export async function groupsCommand(verbose: boolean): Promise<number> {
28
28
  details.push({
29
29
  name,
30
30
  tool: group.tool || '(none)',
31
- restart: group.restart,
31
+ restart: group.restart || '(none)',
32
32
  itemCount: group.items.length,
33
33
  });
34
34
  }
@@ -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
 
@@ -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
- shell: process.platform === 'win32' // Use shell on Windows for better path handling
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
- return Promise.all(killPromises).then(() => {});
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> {