cligr 1.0.7 → 1.0.9
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 +103 -46
- package/docs/superpowers/specs/2026-04-13-improve-web-ui-console-design.md +38 -0
- package/package.json +1 -1
- package/src/commands/groups.ts +1 -1
- package/src/commands/ls.ts +1 -1
- package/src/commands/serve.ts +65 -8
- package/src/config/loader.ts +6 -2
- package/src/config/types.ts +1 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test runner script that compiles TypeScript tests with esbuild
|
|
5
|
+
* and runs them with Node.js built-in test runner
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { build } from 'esbuild';
|
|
9
|
+
import { spawnSync } from 'child_process';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const __dirname = path.dirname(__filename);
|
|
15
|
+
|
|
16
|
+
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
|
17
|
+
const TESTS_DIR = path.join(PROJECT_ROOT, 'tests/integration');
|
|
18
|
+
const DIST_DIR = path.join(PROJECT_ROOT, 'tests-dist');
|
|
19
|
+
const SRC_DIR = path.join(PROJECT_ROOT, 'src');
|
|
20
|
+
|
|
21
|
+
async function buildSourceModules() {
|
|
22
|
+
console.log('Building source modules...');
|
|
23
|
+
|
|
24
|
+
// Build source modules directly to src directory (as .js files alongside .ts)
|
|
25
|
+
const dirs = ['config', 'process', 'commands'];
|
|
26
|
+
|
|
27
|
+
for (const dir of dirs) {
|
|
28
|
+
const dirPath = path.join(SRC_DIR, dir);
|
|
29
|
+
if (!fs.existsSync(dirPath)) continue;
|
|
30
|
+
|
|
31
|
+
const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.ts'));
|
|
32
|
+
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
const inFile = path.join(dirPath, file);
|
|
35
|
+
const outFile = path.join(dirPath, file.replace('.ts', '.js'));
|
|
36
|
+
|
|
37
|
+
await build({
|
|
38
|
+
entryPoints: [inFile],
|
|
39
|
+
bundle: false,
|
|
40
|
+
platform: 'node',
|
|
41
|
+
target: 'es2022',
|
|
42
|
+
format: 'esm',
|
|
43
|
+
outfile: outFile,
|
|
44
|
+
absWorkingDir: PROJECT_ROOT,
|
|
45
|
+
logLevel: 'silent',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log('Built source modules');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function buildTests(skipBlocking = false) {
|
|
54
|
+
console.log('Building TypeScript tests...');
|
|
55
|
+
|
|
56
|
+
// Clean dist directory
|
|
57
|
+
if (fs.existsSync(DIST_DIR)) {
|
|
58
|
+
fs.rmSync(DIST_DIR, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
fs.mkdirSync(DIST_DIR, { recursive: true });
|
|
61
|
+
|
|
62
|
+
// Also create tests/integration subdirectory to match original structure
|
|
63
|
+
const outputSubDir = path.join(DIST_DIR, 'integration');
|
|
64
|
+
fs.mkdirSync(outputSubDir, { recursive: true });
|
|
65
|
+
|
|
66
|
+
// Find all test files
|
|
67
|
+
let testFiles = fs.readdirSync(TESTS_DIR)
|
|
68
|
+
.filter(f => f.endsWith('.test.ts'))
|
|
69
|
+
.map(f => path.join(TESTS_DIR, f));
|
|
70
|
+
|
|
71
|
+
// Skip blocking-processes tests by default (they have infinite loops)
|
|
72
|
+
if (skipBlocking) {
|
|
73
|
+
testFiles = testFiles.filter(f => !f.includes('blocking-processes'));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Build each test file
|
|
77
|
+
for (const testFile of testFiles) {
|
|
78
|
+
const outputFile = path.join(
|
|
79
|
+
outputSubDir,
|
|
80
|
+
path.basename(testFile).replace('.ts', '.js')
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
await build({
|
|
84
|
+
entryPoints: [testFile],
|
|
85
|
+
bundle: false,
|
|
86
|
+
platform: 'node',
|
|
87
|
+
target: 'es2022',
|
|
88
|
+
format: 'esm',
|
|
89
|
+
outfile: outputFile,
|
|
90
|
+
absWorkingDir: PROJECT_ROOT,
|
|
91
|
+
logLevel: 'error',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log(`Built ${testFiles.length} test files`);
|
|
96
|
+
return testFiles.length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function runTests(verbose = false) {
|
|
100
|
+
const testFiles = fs.readdirSync(path.join(DIST_DIR, 'integration'))
|
|
101
|
+
.filter(f => f.endsWith('.test.js'))
|
|
102
|
+
.map(f => path.join(DIST_DIR, 'integration', f));
|
|
103
|
+
|
|
104
|
+
const args = ['--test'];
|
|
105
|
+
if (verbose) {
|
|
106
|
+
args.push('--verbose');
|
|
107
|
+
}
|
|
108
|
+
args.push(...testFiles);
|
|
109
|
+
|
|
110
|
+
console.log('\nRunning tests...\n');
|
|
111
|
+
const result = spawnSync('node', args, {
|
|
112
|
+
stdio: 'inherit',
|
|
113
|
+
cwd: PROJECT_ROOT,
|
|
114
|
+
shell: true
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (result.status !== 0) {
|
|
118
|
+
throw new Error(`Tests failed with exit code ${result.status}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { status: result.status };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function cleanupSourceModules() {
|
|
125
|
+
// Remove generated .js files from src directory
|
|
126
|
+
const dirs = ['config', 'process', 'commands'];
|
|
127
|
+
|
|
128
|
+
for (const dir of dirs) {
|
|
129
|
+
const dirPath = path.join(SRC_DIR, dir);
|
|
130
|
+
if (!fs.existsSync(dirPath)) continue;
|
|
131
|
+
|
|
132
|
+
const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.js'));
|
|
133
|
+
|
|
134
|
+
for (const file of files) {
|
|
135
|
+
const filePath = path.join(dirPath, file);
|
|
136
|
+
fs.unlinkSync(filePath);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function main() {
|
|
142
|
+
const args = process.argv.slice(2);
|
|
143
|
+
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
144
|
+
const includeBlocking = args.includes('--include-blocking') || args.includes('-b');
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
await buildSourceModules();
|
|
148
|
+
await buildTests(!includeBlocking);
|
|
149
|
+
runTests(verbose);
|
|
150
|
+
|
|
151
|
+
// Clean up
|
|
152
|
+
cleanupSourceModules();
|
|
153
|
+
if (fs.existsSync(DIST_DIR)) {
|
|
154
|
+
fs.rmSync(DIST_DIR, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error('Test failed:', error.message);
|
|
158
|
+
// Still try to clean up on error
|
|
159
|
+
try {
|
|
160
|
+
cleanupSourceModules();
|
|
161
|
+
} catch (e) {
|
|
162
|
+
// Ignore cleanup errors
|
|
163
|
+
}
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
main();
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
const CONFIG_FILENAME = '.cligr.yml';
|
|
7
|
+
const TEMPLATE = `# Cligr Configuration
|
|
8
|
+
|
|
9
|
+
groups:
|
|
10
|
+
web:
|
|
11
|
+
tool: docker
|
|
12
|
+
restart: false
|
|
13
|
+
items:
|
|
14
|
+
- "nginx,8080" # $1=nginx (name), $2=8080 (port)
|
|
15
|
+
- "nginx,3000"
|
|
16
|
+
|
|
17
|
+
simple:
|
|
18
|
+
tool: node
|
|
19
|
+
items:
|
|
20
|
+
- "server" # $1=server (name only)
|
|
21
|
+
|
|
22
|
+
tools:
|
|
23
|
+
docker:
|
|
24
|
+
cmd: "docker run -p $2:$2 nginx" # $1=name, $2=port
|
|
25
|
+
node:
|
|
26
|
+
cmd: "node $1.js" # $1=file name
|
|
27
|
+
|
|
28
|
+
# Syntax:
|
|
29
|
+
# - Items are comma-separated: "name,arg2,arg3"
|
|
30
|
+
# - $1 = name (first value)
|
|
31
|
+
# - $2, $3... = additional arguments
|
|
32
|
+
# - If no tool specified, executes directly
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
function detectEditor(): string {
|
|
36
|
+
const platform = process.platform;
|
|
37
|
+
|
|
38
|
+
// Try VS Code first
|
|
39
|
+
const whichCmd = platform === 'win32' ? 'where' : 'which';
|
|
40
|
+
const codeCheck = spawnSync(whichCmd, ['code'], { stdio: 'ignore' });
|
|
41
|
+
if (codeCheck.status === 0) {
|
|
42
|
+
return 'code';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Try EDITOR environment variable
|
|
46
|
+
if (process.env.EDITOR) {
|
|
47
|
+
return process.env.EDITOR;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Platform defaults
|
|
51
|
+
if (platform === 'win32') {
|
|
52
|
+
return 'notepad.exe';
|
|
53
|
+
}
|
|
54
|
+
return 'vim';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function spawnEditor(filePath: string, editorCmd: string): void {
|
|
58
|
+
// Check if editor exists before spawning
|
|
59
|
+
const platform = process.platform;
|
|
60
|
+
const whichCmd = platform === 'win32' ? 'where' : 'which';
|
|
61
|
+
const editorCheck = spawnSync(whichCmd, [editorCmd], { stdio: 'ignore' });
|
|
62
|
+
|
|
63
|
+
if (editorCheck.status !== 0 && editorCmd !== process.env.EDITOR) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Editor '${editorCmd}' not found.\n` +
|
|
66
|
+
`Install VS Code or set EDITOR environment variable.\n\n` +
|
|
67
|
+
`Example:\n` +
|
|
68
|
+
` export EDITOR=vim\n` +
|
|
69
|
+
` cligr config`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Spawn detached so terminal is not blocked
|
|
74
|
+
const child = spawn(editorCmd, [filePath], {
|
|
75
|
+
detached: true,
|
|
76
|
+
stdio: 'ignore',
|
|
77
|
+
shell: platform === 'win32',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
child.unref();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createTemplate(filePath: string): void {
|
|
84
|
+
const dir = path.dirname(filePath);
|
|
85
|
+
if (!fs.existsSync(dir)) {
|
|
86
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
87
|
+
}
|
|
88
|
+
fs.writeFileSync(filePath, TEMPLATE, 'utf-8');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function configCommand(): Promise<number> {
|
|
92
|
+
try {
|
|
93
|
+
// Determine config path (same logic as ConfigLoader)
|
|
94
|
+
const homeDirConfig = path.join(os.homedir(), CONFIG_FILENAME);
|
|
95
|
+
const currentDirConfig = path.resolve(CONFIG_FILENAME);
|
|
96
|
+
|
|
97
|
+
let configPath: string;
|
|
98
|
+
if (fs.existsSync(homeDirConfig)) {
|
|
99
|
+
configPath = homeDirConfig;
|
|
100
|
+
} else if (fs.existsSync(currentDirConfig)) {
|
|
101
|
+
configPath = currentDirConfig;
|
|
102
|
+
} else {
|
|
103
|
+
configPath = homeDirConfig;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Create template if doesn't exist
|
|
107
|
+
if (!fs.existsSync(configPath)) {
|
|
108
|
+
createTemplate(configPath);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Detect and open editor
|
|
112
|
+
const editor = detectEditor();
|
|
113
|
+
spawnEditor(configPath, editor);
|
|
114
|
+
|
|
115
|
+
console.log(`Opening ${configPath} in ${editor}...`);
|
|
116
|
+
return 0;
|
|
117
|
+
} catch (err: any) {
|
|
118
|
+
console.error(`Error: ${err.message}`);
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -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 || '(none)',
|
|
32
|
+
itemCount: Object.keys(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,25 @@
|
|
|
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, items } = 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 items) {
|
|
15
|
+
console.log(` ${item.name}: ${item.value}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
console.log('');
|
|
19
|
+
|
|
20
|
+
return 0;
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error((err as Error).message);
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ConfigLoader } from '../config/loader.js';
|
|
2
|
+
import { TemplateExpander } from '../process/template.js';
|
|
3
|
+
import { ProcessManager } from '../process/manager.js';
|
|
4
|
+
import { PidStore } from '../process/pid-store.js';
|
|
5
|
+
|
|
6
|
+
export async function upCommand(groupName: string): Promise<number> {
|
|
7
|
+
const loader = new ConfigLoader();
|
|
8
|
+
const manager = new ProcessManager();
|
|
9
|
+
const pidStore = new PidStore();
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
// Clean up any stale PID files for this group on startup
|
|
13
|
+
await pidStore.cleanupStalePids();
|
|
14
|
+
|
|
15
|
+
// Load group config
|
|
16
|
+
const { config, items, tool, toolTemplate, params } = loader.getGroup(groupName);
|
|
17
|
+
|
|
18
|
+
// Build process items
|
|
19
|
+
const processItems = items.map((item, index) =>
|
|
20
|
+
TemplateExpander.parseItem(tool, toolTemplate, item, index, params)
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
// Spawn all processes
|
|
24
|
+
manager.spawnGroup(groupName, processItems, config.restart);
|
|
25
|
+
|
|
26
|
+
console.log(`Started group ${groupName} with ${processItems.length} process(es)`);
|
|
27
|
+
console.log('Press Ctrl+C to stop...');
|
|
28
|
+
|
|
29
|
+
// Wait for signals
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const cleanup = async () => {
|
|
32
|
+
console.log('\nShutting down...');
|
|
33
|
+
process.removeListener('SIGINT', cleanup);
|
|
34
|
+
process.removeListener('SIGTERM', cleanup);
|
|
35
|
+
await manager.killAll();
|
|
36
|
+
resolve(0);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
process.on('SIGINT', cleanup);
|
|
40
|
+
process.on('SIGTERM', cleanup);
|
|
41
|
+
});
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error instanceof Error && error.name === 'ConfigError') {
|
|
44
|
+
console.error(error.message);
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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, ItemEntry } 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
|
+
// Validate each group's items
|
|
72
|
+
for (const [groupName, group] of Object.entries(cfg.groups as Record<string, unknown>)) {
|
|
73
|
+
if (group && typeof group === 'object') {
|
|
74
|
+
const groupObj = group as Record<string, unknown>;
|
|
75
|
+
this.validateItems(groupObj.items, groupName);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return cfg as unknown as CliGrConfig;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private validateItems(items: unknown, groupName: string): void {
|
|
83
|
+
if (!items || typeof items !== 'object' || Array.isArray(items)) {
|
|
84
|
+
throw new ConfigError(
|
|
85
|
+
`Group "${groupName}": items must be an object with named entries, e.g.:\n` +
|
|
86
|
+
' items:\n' +
|
|
87
|
+
' serviceName: "value1,value2"'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const seenNames = new Set<string>();
|
|
92
|
+
|
|
93
|
+
for (const [name, value] of Object.entries(items as Record<string, unknown>)) {
|
|
94
|
+
if (typeof value !== 'string') {
|
|
95
|
+
throw new ConfigError(`Group "${groupName}": item "${name}" must have a string value`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (seenNames.has(name)) {
|
|
99
|
+
throw new ConfigError(
|
|
100
|
+
`Group "${groupName}": duplicate item name "${name}". ` +
|
|
101
|
+
`Item names must be unique within a group.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
seenNames.add(name);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private normalizeItems(items: Record<string, string>): ItemEntry[] {
|
|
109
|
+
return Object.entries(items).map(([name, value]) => ({
|
|
110
|
+
name,
|
|
111
|
+
value
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getGroup(name: string): { config: GroupConfig; items: ItemEntry[]; tool: string | null; toolTemplate: string | null; params: Record<string, string> } {
|
|
116
|
+
const config = this.load();
|
|
117
|
+
const group = config.groups[name];
|
|
118
|
+
|
|
119
|
+
if (!group) {
|
|
120
|
+
const available = Object.keys(config.groups).join(', ');
|
|
121
|
+
throw new ConfigError(`Unknown group: ${name}. Available: ${available}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Normalize items to ItemEntry[]
|
|
125
|
+
const items = this.normalizeItems(group.items);
|
|
126
|
+
|
|
127
|
+
// Resolve tool
|
|
128
|
+
let toolTemplate: string | null = null;
|
|
129
|
+
let tool: string | null = null;
|
|
130
|
+
|
|
131
|
+
if (config.tools && config.tools[group.tool]) {
|
|
132
|
+
toolTemplate = config.tools[group.tool].cmd;
|
|
133
|
+
tool = group.tool;
|
|
134
|
+
} else {
|
|
135
|
+
tool = null;
|
|
136
|
+
toolTemplate = null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const params = group.params || {};
|
|
140
|
+
|
|
141
|
+
return { config: group, items, tool, toolTemplate, params };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
listGroups(): string[] {
|
|
145
|
+
const config = this.load();
|
|
146
|
+
return Object.keys(config.groups);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface ToolConfig {
|
|
2
|
+
cmd: string;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface ItemEntry {
|
|
6
|
+
name: string; // the key from config (e.g., "nginxService1")
|
|
7
|
+
value: string; // the value string (e.g., "nginx,8080")
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface GroupConfig {
|
|
11
|
+
tool: string;
|
|
12
|
+
restart?: 'yes' | 'no' | 'unless-stopped';
|
|
13
|
+
params?: Record<string, string>;
|
|
14
|
+
items: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CliGrConfig {
|
|
18
|
+
tools?: Record<string, ToolConfig>;
|
|
19
|
+
groups: Record<string, GroupConfig>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ProcessItem {
|
|
23
|
+
name: string;
|
|
24
|
+
args: string[];
|
|
25
|
+
fullCmd: string;
|
|
26
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { upCommand } from './commands/up.js';
|
|
4
|
+
import { lsCommand } from './commands/ls.js';
|
|
5
|
+
import { configCommand } from './commands/config.js';
|
|
6
|
+
import { groupsCommand } from './commands/groups.js';
|
|
7
|
+
|
|
8
|
+
async function main(): Promise<void> {
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
|
|
11
|
+
if (args.length === 0) {
|
|
12
|
+
printUsage();
|
|
13
|
+
process.exit(1);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const [firstArg, ...rest] = args;
|
|
18
|
+
let verbose = false;
|
|
19
|
+
|
|
20
|
+
// Check if this is a known command
|
|
21
|
+
const knownCommands = ['config', 'up', 'ls', 'groups'];
|
|
22
|
+
|
|
23
|
+
if (knownCommands.includes(firstArg)) {
|
|
24
|
+
// It's a command
|
|
25
|
+
const command = firstArg;
|
|
26
|
+
let groupName: string | undefined;
|
|
27
|
+
|
|
28
|
+
// Parse flags for commands that support them
|
|
29
|
+
if (command === 'groups') {
|
|
30
|
+
// groups command supports -v/--verbose flags
|
|
31
|
+
const flagIndex = rest.findIndex(arg => arg === '-v' || arg === '--verbose');
|
|
32
|
+
if (flagIndex !== -1) {
|
|
33
|
+
verbose = true;
|
|
34
|
+
// Remove the flag from rest so it's not treated as a group name
|
|
35
|
+
rest.splice(flagIndex, 1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
groupName = rest[0];
|
|
39
|
+
|
|
40
|
+
// config and groups commands don't require group name
|
|
41
|
+
if (command !== 'config' && command !== 'groups' && !groupName) {
|
|
42
|
+
console.error('Error: group name required');
|
|
43
|
+
printUsage();
|
|
44
|
+
process.exit(1);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let exitCode = 0;
|
|
49
|
+
|
|
50
|
+
switch (command) {
|
|
51
|
+
case 'config':
|
|
52
|
+
exitCode = await configCommand();
|
|
53
|
+
break;
|
|
54
|
+
case 'up':
|
|
55
|
+
exitCode = await upCommand(groupName!);
|
|
56
|
+
break;
|
|
57
|
+
case 'ls':
|
|
58
|
+
exitCode = await lsCommand(groupName!);
|
|
59
|
+
break;
|
|
60
|
+
case 'groups':
|
|
61
|
+
exitCode = await groupsCommand(verbose);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
process.exit(exitCode);
|
|
66
|
+
} else {
|
|
67
|
+
// Treat as a group name - run up command
|
|
68
|
+
const exitCode = await upCommand(firstArg);
|
|
69
|
+
process.exit(exitCode);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function printUsage(): void {
|
|
74
|
+
console.log(`
|
|
75
|
+
Usage: cligr <group> | <command> [options]
|
|
76
|
+
|
|
77
|
+
Commands:
|
|
78
|
+
config Open config file in editor
|
|
79
|
+
ls <group> List all items in the group
|
|
80
|
+
groups [-v|--verbose] List all groups
|
|
81
|
+
|
|
82
|
+
Options:
|
|
83
|
+
-v, --verbose Show detailed group information
|
|
84
|
+
|
|
85
|
+
Examples:
|
|
86
|
+
cligr test1 Start all processes in test1 group
|
|
87
|
+
cligr config
|
|
88
|
+
cligr ls test1
|
|
89
|
+
cligr groups
|
|
90
|
+
cligr groups -v
|
|
91
|
+
`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
main().catch((err) => {
|
|
95
|
+
console.error(err);
|
|
96
|
+
process.exit(2);
|
|
97
|
+
});
|