cligr 1.0.0
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/settings.local.json +12 -0
- package/README.md +65 -0
- package/dist/index.js +94 -0
- package/package.json +26 -0
- package/scripts/build.js +20 -0
- package/scripts/test.js +164 -0
- package/src/commands/config.ts +121 -0
- package/src/commands/down.ts +6 -0
- package/src/commands/groups.ts +68 -0
- package/src/commands/ls.ts +26 -0
- package/src/commands/up.ts +44 -0
- package/src/config/loader.ts +103 -0
- package/src/config/types.ts +20 -0
- package/src/index.ts +96 -0
- package/src/process/manager.ts +199 -0
- package/src/process/template.ts +72 -0
- package/tests/integration/blocking-processes-fixed.test.ts +255 -0
- package/tests/integration/blocking-processes.test.ts +497 -0
- package/tests/integration/commands.test.ts +674 -0
- package/tests/integration/config-loader.test.ts +426 -0
- package/tests/integration/process-manager.test.ts +391 -0
- package/tests/integration/template-expander.test.ts +362 -0
- package/tsconfig.json +15 -0
- package/usage.md +9 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ConfigLoader } from '../config/loader.js';
|
|
2
|
+
|
|
3
|
+
interface GroupDetails {
|
|
4
|
+
name: string;
|
|
5
|
+
tool: string;
|
|
6
|
+
restart: string;
|
|
7
|
+
itemCount: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function groupsCommand(verbose: boolean): Promise<number> {
|
|
11
|
+
const loader = new ConfigLoader();
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const groupNames = loader.listGroups();
|
|
15
|
+
|
|
16
|
+
if (groupNames.length === 0) {
|
|
17
|
+
// No groups defined - empty output
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (verbose) {
|
|
22
|
+
// Verbose mode: gather details and print table
|
|
23
|
+
const config = loader.load();
|
|
24
|
+
const details: GroupDetails[] = [];
|
|
25
|
+
|
|
26
|
+
for (const name of groupNames) {
|
|
27
|
+
const group = config.groups[name];
|
|
28
|
+
details.push({
|
|
29
|
+
name,
|
|
30
|
+
tool: group.tool || '(none)',
|
|
31
|
+
restart: group.restart,
|
|
32
|
+
itemCount: group.items.length,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Calculate column widths
|
|
37
|
+
const maxNameLen = Math.max('GROUP'.length, ...details.map(d => d.name.length));
|
|
38
|
+
const maxToolLen = Math.max('TOOL'.length, ...details.map(d => d.tool.length));
|
|
39
|
+
const maxRestartLen = Math.max('RESTART'.length, ...details.map(d => d.restart.length));
|
|
40
|
+
|
|
41
|
+
// Print header
|
|
42
|
+
const header = 'GROUP'.padEnd(maxNameLen) + ' ' +
|
|
43
|
+
'TOOL'.padEnd(maxToolLen) + ' ' +
|
|
44
|
+
'RESTART'.padEnd(maxRestartLen) + ' ' +
|
|
45
|
+
'ITEMS';
|
|
46
|
+
console.log(header);
|
|
47
|
+
|
|
48
|
+
// Print rows
|
|
49
|
+
for (const d of details) {
|
|
50
|
+
const row = d.name.padEnd(maxNameLen) + ' ' +
|
|
51
|
+
d.tool.padEnd(maxToolLen) + ' ' +
|
|
52
|
+
d.restart.padEnd(maxRestartLen) + ' ' +
|
|
53
|
+
String(d.itemCount);
|
|
54
|
+
console.log(row);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
// Simple mode: just list names
|
|
58
|
+
for (const name of groupNames) {
|
|
59
|
+
console.log(name);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return 0;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error((err as Error).message);
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ConfigLoader } from '../config/loader.js';
|
|
2
|
+
|
|
3
|
+
export async function lsCommand(groupName: string): Promise<number> {
|
|
4
|
+
const loader = new ConfigLoader();
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
const { config } = loader.getGroup(groupName);
|
|
8
|
+
|
|
9
|
+
console.log(`\nGroup: ${groupName}`);
|
|
10
|
+
console.log(`Tool: ${config.tool}`);
|
|
11
|
+
console.log(`Restart: ${config.restart}`);
|
|
12
|
+
console.log('\nItems:');
|
|
13
|
+
|
|
14
|
+
for (const item of config.items) {
|
|
15
|
+
// Show the full item string as-is in the output
|
|
16
|
+
console.log(` - ${item}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
console.log('');
|
|
20
|
+
|
|
21
|
+
return 0;
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.error((err as Error).message);
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ConfigLoader } from '../config/loader.js';
|
|
2
|
+
import { TemplateExpander } from '../process/template.js';
|
|
3
|
+
import { ProcessManager } from '../process/manager.js';
|
|
4
|
+
|
|
5
|
+
export async function upCommand(groupName: string): Promise<number> {
|
|
6
|
+
const loader = new ConfigLoader();
|
|
7
|
+
const manager = new ProcessManager();
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
// Load group config
|
|
11
|
+
const { config, tool, toolTemplate } = loader.getGroup(groupName);
|
|
12
|
+
|
|
13
|
+
// Build process items
|
|
14
|
+
const items = config.items.map((itemStr, index) =>
|
|
15
|
+
TemplateExpander.parseItem(tool, toolTemplate, itemStr, index)
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
// Spawn all processes
|
|
19
|
+
manager.spawnGroup(groupName, items, config.restart);
|
|
20
|
+
|
|
21
|
+
console.log(`Started group ${groupName} with ${items.length} process(es)`);
|
|
22
|
+
console.log('Press Ctrl+C to stop...');
|
|
23
|
+
|
|
24
|
+
// Wait for signals
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const cleanup = async () => {
|
|
27
|
+
console.log('\nShutting down...');
|
|
28
|
+
process.removeListener('SIGINT', cleanup);
|
|
29
|
+
process.removeListener('SIGTERM', cleanup);
|
|
30
|
+
await manager.killAll();
|
|
31
|
+
resolve(0);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
process.on('SIGINT', cleanup);
|
|
35
|
+
process.on('SIGTERM', cleanup);
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error instanceof Error && error.name === 'ConfigError') {
|
|
39
|
+
console.error(error.message);
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
import type { CliGrConfig, GroupConfig, ToolConfig } from './types.js';
|
|
6
|
+
|
|
7
|
+
const CONFIG_FILENAME = '.cligr.yml';
|
|
8
|
+
|
|
9
|
+
export class ConfigError extends Error {
|
|
10
|
+
constructor(message: string) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'ConfigError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ConfigLoader {
|
|
17
|
+
private configPath: string;
|
|
18
|
+
|
|
19
|
+
constructor(configPath?: string) {
|
|
20
|
+
if (configPath) {
|
|
21
|
+
// User provided explicit path
|
|
22
|
+
this.configPath = path.resolve(configPath);
|
|
23
|
+
} else {
|
|
24
|
+
// Auto-detect: home dir first, then current dir
|
|
25
|
+
const homeDirConfig = path.join(os.homedir(), CONFIG_FILENAME);
|
|
26
|
+
const currentDirConfig = path.resolve(CONFIG_FILENAME);
|
|
27
|
+
|
|
28
|
+
if (fs.existsSync(homeDirConfig)) {
|
|
29
|
+
this.configPath = homeDirConfig;
|
|
30
|
+
} else if (fs.existsSync(currentDirConfig)) {
|
|
31
|
+
this.configPath = currentDirConfig;
|
|
32
|
+
} else {
|
|
33
|
+
// Store home dir as default, will error in load()
|
|
34
|
+
this.configPath = homeDirConfig;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
load(): CliGrConfig {
|
|
40
|
+
if (!fs.existsSync(this.configPath)) {
|
|
41
|
+
throw new ConfigError(
|
|
42
|
+
`Config file not found. Looking for:\n` +
|
|
43
|
+
` - ${path.join(os.homedir(), CONFIG_FILENAME)}\n` +
|
|
44
|
+
` - ${path.resolve(CONFIG_FILENAME)}`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const content = fs.readFileSync(this.configPath, 'utf-8');
|
|
49
|
+
let config: unknown;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
config = yaml.load(content);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
throw new ConfigError(`Invalid YAML: ${(err as Error).message}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return this.validate(config);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private validate(config: unknown): CliGrConfig {
|
|
61
|
+
if (!config || typeof config !== 'object') {
|
|
62
|
+
throw new ConfigError('Config must be an object');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const cfg = config as Record<string, unknown>;
|
|
66
|
+
|
|
67
|
+
if (!cfg.groups || typeof cfg.groups !== 'object') {
|
|
68
|
+
throw new ConfigError('Config must have a "groups" object');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return cfg as CliGrConfig;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getGroup(name: string): { config: GroupConfig; tool: string | null; toolTemplate: string | null } {
|
|
75
|
+
const config = this.load();
|
|
76
|
+
const group = config.groups[name];
|
|
77
|
+
|
|
78
|
+
if (!group) {
|
|
79
|
+
const available = Object.keys(config.groups).join(', ');
|
|
80
|
+
throw new ConfigError(`Unknown group: ${name}. Available: ${available}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Resolve tool
|
|
84
|
+
let toolTemplate: string | null = null;
|
|
85
|
+
let tool: string | null = null;
|
|
86
|
+
|
|
87
|
+
if (config.tools && config.tools[group.tool]) {
|
|
88
|
+
toolTemplate = config.tools[group.tool].cmd;
|
|
89
|
+
tool = group.tool;
|
|
90
|
+
} else {
|
|
91
|
+
// Tool might be a direct executable
|
|
92
|
+
tool = null;
|
|
93
|
+
toolTemplate = null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { config: group, tool, toolTemplate };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
listGroups(): string[] {
|
|
100
|
+
const config = this.load();
|
|
101
|
+
return Object.keys(config.groups);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface ToolConfig {
|
|
2
|
+
cmd: string;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface GroupConfig {
|
|
6
|
+
tool: string;
|
|
7
|
+
restart: 'yes' | 'no' | 'unless-stopped';
|
|
8
|
+
items: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CliGrConfig {
|
|
12
|
+
tools?: Record<string, ToolConfig>;
|
|
13
|
+
groups: Record<string, GroupConfig>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ProcessItem {
|
|
17
|
+
name: string;
|
|
18
|
+
args: string[];
|
|
19
|
+
fullCmd: string;
|
|
20
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { upCommand } from './commands/up.js';
|
|
4
|
+
import { lsCommand } from './commands/ls.js';
|
|
5
|
+
import { downCommand } from './commands/down.js';
|
|
6
|
+
import { configCommand } from './commands/config.js';
|
|
7
|
+
import { groupsCommand } from './commands/groups.js';
|
|
8
|
+
|
|
9
|
+
async function main(): Promise<void> {
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
|
|
12
|
+
if (args.length === 0) {
|
|
13
|
+
printUsage();
|
|
14
|
+
process.exit(1);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const [command, ...rest] = args;
|
|
19
|
+
let groupName: string | undefined;
|
|
20
|
+
let verbose = false;
|
|
21
|
+
|
|
22
|
+
// Parse flags for commands that support them
|
|
23
|
+
if (command === 'groups') {
|
|
24
|
+
// groups command supports -v/--verbose flags
|
|
25
|
+
const flagIndex = rest.findIndex(arg => arg === '-v' || arg === '--verbose');
|
|
26
|
+
if (flagIndex !== -1) {
|
|
27
|
+
verbose = true;
|
|
28
|
+
// Remove the flag from rest so it's not treated as a group name
|
|
29
|
+
rest.splice(flagIndex, 1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
groupName = rest[0];
|
|
33
|
+
|
|
34
|
+
// config and groups commands don't require group name
|
|
35
|
+
if (command !== 'config' && command !== 'groups' && !groupName) {
|
|
36
|
+
console.error('Error: group name required');
|
|
37
|
+
printUsage();
|
|
38
|
+
process.exit(1);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let exitCode = 0;
|
|
43
|
+
|
|
44
|
+
switch (command) {
|
|
45
|
+
case 'config':
|
|
46
|
+
exitCode = await configCommand();
|
|
47
|
+
break;
|
|
48
|
+
case 'up':
|
|
49
|
+
exitCode = await upCommand(groupName);
|
|
50
|
+
break;
|
|
51
|
+
case 'ls':
|
|
52
|
+
exitCode = await lsCommand(groupName);
|
|
53
|
+
break;
|
|
54
|
+
case 'down':
|
|
55
|
+
exitCode = await downCommand(groupName);
|
|
56
|
+
break;
|
|
57
|
+
case 'groups':
|
|
58
|
+
exitCode = await groupsCommand(verbose);
|
|
59
|
+
break;
|
|
60
|
+
default:
|
|
61
|
+
console.error(`Error: unknown command '${command}'`);
|
|
62
|
+
printUsage();
|
|
63
|
+
exitCode = 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
process.exit(exitCode);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function printUsage(): void {
|
|
70
|
+
console.log(`
|
|
71
|
+
Usage: cligr <command> [options] [group]
|
|
72
|
+
|
|
73
|
+
Commands:
|
|
74
|
+
config Open config file in editor
|
|
75
|
+
up <group> Start all processes in the group
|
|
76
|
+
ls <group> List all items in the group
|
|
77
|
+
down <group> Stop the group (Ctrl+C also works)
|
|
78
|
+
groups [-v|--verbose] List all groups
|
|
79
|
+
|
|
80
|
+
Options:
|
|
81
|
+
-v, --verbose Show detailed group information
|
|
82
|
+
|
|
83
|
+
Examples:
|
|
84
|
+
cligr config
|
|
85
|
+
cligr up test1
|
|
86
|
+
cligr ls test1
|
|
87
|
+
cligr down test1
|
|
88
|
+
cligr groups
|
|
89
|
+
cligr groups -v
|
|
90
|
+
`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
main().catch((err) => {
|
|
94
|
+
console.error(err);
|
|
95
|
+
process.exit(2);
|
|
96
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { spawn, ChildProcess } from 'child_process';
|
|
2
|
+
import type { GroupConfig, ProcessItem } from '../config/types.js';
|
|
3
|
+
|
|
4
|
+
export type ProcessStatus = 'running' | 'stopped' | 'failed';
|
|
5
|
+
|
|
6
|
+
export class ManagedProcess {
|
|
7
|
+
constructor(
|
|
8
|
+
public item: ProcessItem,
|
|
9
|
+
public process: ChildProcess,
|
|
10
|
+
public status: ProcessStatus = 'running'
|
|
11
|
+
) {}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ProcessManager {
|
|
15
|
+
private groups = new Map<string, ManagedProcess[]>();
|
|
16
|
+
private restartTimestamps = new Map<string, number[]>();
|
|
17
|
+
private readonly maxRestarts = 3;
|
|
18
|
+
private readonly restartWindow = 10000; // 10 seconds
|
|
19
|
+
|
|
20
|
+
spawnGroup(groupName: string, items: ProcessItem[], restartPolicy: GroupConfig['restart']): void {
|
|
21
|
+
if (this.groups.has(groupName)) {
|
|
22
|
+
throw new Error(`Group ${groupName} is already running`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const processes: ManagedProcess[] = [];
|
|
26
|
+
|
|
27
|
+
for (const item of items) {
|
|
28
|
+
const proc = this.spawnProcess(item, groupName, restartPolicy);
|
|
29
|
+
processes.push(new ManagedProcess(item, proc));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
this.groups.set(groupName, processes);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private spawnProcess(item: ProcessItem, groupName: string, restartPolicy: GroupConfig['restart']): ChildProcess {
|
|
36
|
+
// Parse command into executable and args, handling quoted strings
|
|
37
|
+
const { cmd, args } = this.parseCommand(item.fullCmd);
|
|
38
|
+
|
|
39
|
+
const proc = spawn(cmd, args, {
|
|
40
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
41
|
+
shell: process.platform === 'win32' // Use shell on Windows for better path handling
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Prefix output with item name
|
|
45
|
+
if (proc.stdout) {
|
|
46
|
+
proc.stdout.on('data', (data) => {
|
|
47
|
+
process.stdout.write(`[${item.name}] ${data}`);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (proc.stderr) {
|
|
52
|
+
proc.stderr.on('data', (data) => {
|
|
53
|
+
process.stderr.write(`[${item.name}] ${data}`);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Handle exit and restart
|
|
58
|
+
proc.on('exit', (code, signal) => {
|
|
59
|
+
this.handleExit(groupName, item, restartPolicy, code, signal);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return proc;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private parseCommand(fullCmd: string): { cmd: string; args: string[] } {
|
|
66
|
+
// Handle quoted strings for Windows paths with spaces
|
|
67
|
+
const args: string[] = [];
|
|
68
|
+
let current = '';
|
|
69
|
+
let inQuote = false;
|
|
70
|
+
let quoteChar = '';
|
|
71
|
+
|
|
72
|
+
for (let i = 0; i < fullCmd.length; i++) {
|
|
73
|
+
const char = fullCmd[i];
|
|
74
|
+
const nextChar = fullCmd[i + 1];
|
|
75
|
+
|
|
76
|
+
if ((char === '"' || char === "'") && !inQuote) {
|
|
77
|
+
inQuote = true;
|
|
78
|
+
quoteChar = char;
|
|
79
|
+
} else if (char === quoteChar && inQuote) {
|
|
80
|
+
inQuote = false;
|
|
81
|
+
quoteChar = '';
|
|
82
|
+
} else if (char === ' ' && !inQuote) {
|
|
83
|
+
if (current) {
|
|
84
|
+
args.push(current);
|
|
85
|
+
current = '';
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
current += char;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (current) {
|
|
93
|
+
args.push(current);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { cmd: args[0] || '', args: args.slice(1) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private handleExit(groupName: string, item: ProcessItem, restartPolicy: GroupConfig['restart'], code: number | null, signal: NodeJS.Signals | null): void {
|
|
100
|
+
// Check if killed by cligr (don't restart if unless-stopped)
|
|
101
|
+
// SIGTERM works on both Unix and Windows in Node.js
|
|
102
|
+
if (restartPolicy === 'unless-stopped' && signal === 'SIGTERM') {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check restart policy
|
|
107
|
+
if (restartPolicy === 'no') {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check for crash loop (within the restart window)
|
|
112
|
+
const key = `${groupName}-${item.name}`;
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
const timestamps = this.restartTimestamps.get(key) || [];
|
|
115
|
+
|
|
116
|
+
// Filter out timestamps outside the restart window
|
|
117
|
+
const recentTimestamps = timestamps.filter(ts => now - ts < this.restartWindow);
|
|
118
|
+
recentTimestamps.push(now);
|
|
119
|
+
this.restartTimestamps.set(key, recentTimestamps);
|
|
120
|
+
|
|
121
|
+
if (recentTimestamps.length > this.maxRestarts) {
|
|
122
|
+
console.error(`[${item.name}] Crash loop detected. Stopping restarts.`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Restart after delay
|
|
127
|
+
setTimeout(() => {
|
|
128
|
+
console.log(`[${item.name}] Restarting... (exit code: ${code})`);
|
|
129
|
+
const newProc = this.spawnProcess(item, groupName, restartPolicy);
|
|
130
|
+
|
|
131
|
+
// Update the ManagedProcess in the groups Map with the new process handle
|
|
132
|
+
const processes = this.groups.get(groupName);
|
|
133
|
+
if (processes) {
|
|
134
|
+
const managedProc = processes.find(mp => mp.item.name === item.name);
|
|
135
|
+
if (managedProc) {
|
|
136
|
+
managedProc.process = newProc;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}, 1000);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
killGroup(groupName: string): Promise<void> {
|
|
143
|
+
const processes = this.groups.get(groupName);
|
|
144
|
+
if (!processes) return Promise.resolve();
|
|
145
|
+
|
|
146
|
+
const killPromises = processes.map(mp => this.killProcess(mp.process));
|
|
147
|
+
|
|
148
|
+
this.groups.delete(groupName);
|
|
149
|
+
return Promise.all(killPromises).then(() => {});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private killProcess(proc: ChildProcess): Promise<void> {
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
// First try SIGTERM for graceful shutdown
|
|
155
|
+
proc.kill('SIGTERM');
|
|
156
|
+
|
|
157
|
+
// Force kill with SIGKILL after 5 seconds if still running
|
|
158
|
+
const timeout = setTimeout(() => {
|
|
159
|
+
if (!proc.killed) {
|
|
160
|
+
proc.kill('SIGKILL');
|
|
161
|
+
}
|
|
162
|
+
}, 5000);
|
|
163
|
+
|
|
164
|
+
proc.on('exit', () => {
|
|
165
|
+
clearTimeout(timeout);
|
|
166
|
+
resolve();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// If already dead, resolve immediately
|
|
170
|
+
if (proc.killed || proc.exitCode !== null) {
|
|
171
|
+
clearTimeout(timeout);
|
|
172
|
+
resolve();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
killAll(): Promise<void> {
|
|
178
|
+
const killPromises: Promise<void>[] = [];
|
|
179
|
+
for (const groupName of this.groups.keys()) {
|
|
180
|
+
killPromises.push(this.killGroup(groupName));
|
|
181
|
+
}
|
|
182
|
+
return Promise.all(killPromises).then(() => {});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
getGroupStatus(groupName: string): ProcessStatus[] {
|
|
186
|
+
const processes = this.groups.get(groupName);
|
|
187
|
+
if (!processes) return [];
|
|
188
|
+
|
|
189
|
+
return processes.map(mp => mp.status);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
isGroupRunning(groupName: string): boolean {
|
|
193
|
+
return this.groups.has(groupName);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
getRunningGroups(): string[] {
|
|
197
|
+
return Array.from(this.groups.keys());
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ProcessItem } from '../config/types.js';
|
|
2
|
+
|
|
3
|
+
export class TemplateExpander {
|
|
4
|
+
/**
|
|
5
|
+
* Expands a command template with item arguments
|
|
6
|
+
* @param template - Command template with $1, $2, $3 etc.
|
|
7
|
+
* @param itemStr - Comma-separated item args (e.g., "service1,8080,80")
|
|
8
|
+
* @returns ProcessItem with expanded command
|
|
9
|
+
*/
|
|
10
|
+
static expand(template: string, itemStr: string, index: number): ProcessItem {
|
|
11
|
+
const args = itemStr.split(',').map(s => s.trim());
|
|
12
|
+
|
|
13
|
+
// Generate name from first arg or use index
|
|
14
|
+
const name = args[0] || `item-${index}`;
|
|
15
|
+
|
|
16
|
+
// Replace $1, $2, $3 etc. with args
|
|
17
|
+
// Must replace in reverse order to avoid replacing $1 in $10, $11, etc.
|
|
18
|
+
let fullCmd = template;
|
|
19
|
+
for (let i = args.length - 1; i >= 0; i--) {
|
|
20
|
+
const placeholder = `$${i + 1}`;
|
|
21
|
+
fullCmd = fullCmd.replaceAll(placeholder, args[i]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { name, args, fullCmd };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parses item string into command
|
|
29
|
+
* @param tool - Tool name or executable
|
|
30
|
+
* @param toolTemplate - Template from tools config (if registered tool)
|
|
31
|
+
* @param itemStr - Comma-separated args
|
|
32
|
+
* @param index - Item index in group
|
|
33
|
+
*/
|
|
34
|
+
static parseItem(tool: string | null, toolTemplate: string | null, itemStr: string, index: number): ProcessItem {
|
|
35
|
+
if (toolTemplate) {
|
|
36
|
+
// Use registered tool template
|
|
37
|
+
const result = this.expand(toolTemplate, itemStr, index);
|
|
38
|
+
|
|
39
|
+
// If there are more args than placeholders in the template, append them
|
|
40
|
+
// Count unique placeholders in the ORIGINAL template (before replacement)
|
|
41
|
+
const placeholdersInTemplate = (toolTemplate.match(/\$\d+/g) || []);
|
|
42
|
+
// Find the highest placeholder number (e.g., $1, $2 -> highest is 2)
|
|
43
|
+
let maxPlaceholder = 0;
|
|
44
|
+
for (const p of placeholdersInTemplate) {
|
|
45
|
+
const num = parseInt(p.substring(1), 10);
|
|
46
|
+
if (num > maxPlaceholder) maxPlaceholder = num;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Only append remaining args if there were placeholders in the template
|
|
50
|
+
// If there are no placeholders, the template is a complete command
|
|
51
|
+
if (maxPlaceholder > 0 && result.args.length > maxPlaceholder) {
|
|
52
|
+
const remainingArgs = result.args.slice(maxPlaceholder);
|
|
53
|
+
result.fullCmd = `${result.fullCmd} ${remainingArgs.join(' ')}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return result;
|
|
57
|
+
} else {
|
|
58
|
+
// Direct executable - use tool as command prefix
|
|
59
|
+
const args = itemStr.split(',').map(s => s.trim());
|
|
60
|
+
let name = args[0] || `item-${index}`;
|
|
61
|
+
|
|
62
|
+
// If no commas in itemStr, name should be first word only
|
|
63
|
+
if (!itemStr.includes(',') && args.length === 1) {
|
|
64
|
+
const words = args[0].split(/\s+/);
|
|
65
|
+
name = words[0] || `item-${index}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const fullCmd = tool ? `${tool} ${itemStr}` : itemStr;
|
|
69
|
+
return { name, args, fullCmd };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|