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.
@@ -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
+ }