Skip to content

Commit bf2c86c

Browse files
committed
feat: refactoring + tests
1 parent 85bf6e4 commit bf2c86c

19 files changed

+1782
-1112
lines changed

.github/workflows/ci.yml

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Setup Bun
16+
uses: oven-sh/setup-bun@v1
17+
with:
18+
bun-version: latest
19+
20+
- name: Install dependencies
21+
run: bun install
22+
23+
- name: Run linter
24+
run: bun run lint
25+
26+
- name: Run type check
27+
run: tsc --noEmit
28+
29+
- name: Run tests
30+
run: bun test
31+
32+
- name: Build
33+
run: bun run build

package.json

+10-12
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,25 @@
11
{
22
"name": "sinfonia",
3-
"version": "2.0.3",
3+
"version": "2.0.4",
44
"description": "A beautiful process runner for parallel commands with interactive filtering",
55
"module": "dist/index.js",
66
"type": "module",
77
"bin": {
8-
"sinfonia": "./dist/index.js"
8+
"sinfonia": "./dist/cli.js"
99
},
1010
"files": ["dist", "schema.json", "starter.config.json", "README.md", "LICENSE"],
1111
"scripts": {
12-
"build": "bun build ./src/index.ts --outdir ./dist --target node",
13-
"start": "bun run dist/index.js",
14-
"dev": "bun run --watch src/index.ts",
12+
"build": "bun build ./src/cli.ts --outdir ./dist --target node",
13+
"start": "bun run dist/cli.js",
14+
"dev": "bun run --watch src/cli.ts",
1515
"format": "biome format --write .",
1616
"lint": "biome lint .",
1717
"check": "biome check --write .",
18-
"test:counter": "bun test.js test1",
19-
"test:random": "bun test.js test2",
20-
"test:time": "bun test.js test3",
21-
"test:all": "bun run src/index.ts -l \"output_{timestamp}.log\" \"counter=bun test.js test1\" \"random=bun test.js test2\" \"time=bun test.js test3\"",
22-
"test:groups": "bun run src/index.ts \"timers:counter=bun test.js test1\" \"timers:time=bun test.js test3\" \"random:gen1=bun test.js test2\" \"random:gen2=bun test.js test2\"",
23-
"test:deps": "bun run src/index.ts -c test.config.json",
24-
"test:init": "bun run src/index.ts init"
18+
"test": "bun test tests/*.test.ts",
19+
"test:watch": "bun test --watch tests/*.test.ts",
20+
"test:coverage": "bun test --coverage tests/*.test.ts",
21+
"test:init": "bun run src/cli.ts init",
22+
"test:manual": "bun run src/cli.ts \"counter=bun test.js test1\" \"random=bun test.js test2\" \"time=bun test.js test3\""
2523
},
2624
"keywords": ["cli", "process-manager", "parallel", "terminal", "interactive"],
2725
"author": "",

src/cli.ts

+175
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
#!/usr/bin/env bun
2+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
3+
import path from "node:path";
4+
import { program } from "commander";
5+
import { ProcessManager } from "./process/manager.js";
6+
import type { Command, Config, ConfigFile, Group } from "./types/index.js";
7+
import { ANSI_COLORS, getColorCode } from "./utils/colors.js";
8+
import { formatLogPath } from "./utils/time.js";
9+
10+
program
11+
.name("sinfonia")
12+
.description("Run multiple commands in parallel with interactive filtering")
13+
.version("1.0.0");
14+
15+
program
16+
.command("init")
17+
.description("Generate a starter config file")
18+
.option("-f, --force", "Overwrite existing config file")
19+
.action((options) => {
20+
const configPath = path.resolve(process.cwd(), "sinfonia.json");
21+
if (existsSync(configPath) && !options.force) {
22+
console.error("Config file already exists. Use --force to overwrite.");
23+
process.exit(1);
24+
}
25+
26+
try {
27+
const starterConfigPath = path.join(__dirname, "../starter.config.json");
28+
const starterConfig = readFileSync(starterConfigPath, "utf-8");
29+
writeFileSync(configPath, starterConfig);
30+
console.log(`Created config file at ${configPath}`);
31+
} catch (error) {
32+
console.error(`Failed to create config file: ${error}`);
33+
process.exit(1);
34+
}
35+
});
36+
37+
program
38+
.command("start", { isDefault: true })
39+
.description("Start processes")
40+
.option("-c, --config <file>", "Path to config file", "sinfonia.json")
41+
.option("-l, --log-file [file]", "Enable logging to file (use {timestamp} for current date/time)")
42+
.option("-b, --buffer-size <size>", "Number of log lines to keep in memory per process", "100")
43+
.argument("[commands...]", "Simple commands to run (format: [GROUP:]NAME=COMMAND)")
44+
.action((commands: string[], options) => {
45+
let config: Config;
46+
47+
if (options.config && existsSync(options.config)) {
48+
try {
49+
const configPath = path.resolve(process.cwd(), options.config);
50+
const configContent = readFileSync(configPath, "utf-8");
51+
const parsedConfig: ConfigFile = JSON.parse(configContent);
52+
53+
// Ensure all commands have colors
54+
let colorIndex = 0;
55+
56+
const commandsWithColors: Command[] = parsedConfig.commands.map((cmd) => ({
57+
...cmd,
58+
color: cmd.color
59+
? `\x1b[${getColorCode(cmd.color)}m`
60+
: ANSI_COLORS[colorIndex++ % ANSI_COLORS.length],
61+
}));
62+
63+
// Auto-generate groups from commands while respecting explicit group definitions
64+
const autoGroups = new Map<string, Group>();
65+
66+
// First collect all groups from commands
67+
commandsWithColors.forEach((cmd) => {
68+
if (cmd.group && !autoGroups.has(cmd.group)) {
69+
autoGroups.set(cmd.group, {
70+
name: cmd.group,
71+
color: ANSI_COLORS[colorIndex++ % ANSI_COLORS.length],
72+
commands: [],
73+
});
74+
}
75+
if (cmd.group) {
76+
autoGroups.get(cmd.group)?.commands.push(cmd.name);
77+
}
78+
});
79+
80+
// Then override with any explicit group definitions
81+
const groupsWithColors: Group[] =
82+
parsedConfig.groups?.map((group) => ({
83+
...group,
84+
color: group.color
85+
? `\x1b[${getColorCode(group.color)}m`
86+
: autoGroups.get(group.name)?.color || ANSI_COLORS[colorIndex++ % ANSI_COLORS.length],
87+
})) || Array.from(autoGroups.values());
88+
89+
config = {
90+
commands: commandsWithColors,
91+
groups: groupsWithColors,
92+
options: {
93+
bufferSize: Number.parseInt(options.bufferSize, 10),
94+
logFile: options.logFile,
95+
},
96+
};
97+
} catch (_e) {
98+
console.error(`Failed to read config file: ${_e}`);
99+
process.exit(1);
100+
}
101+
} else if (commands.length === 0) {
102+
console.error("No commands specified and no config file found.");
103+
console.log("Run 'sinfonia init' to create a starter config file.");
104+
process.exit(1);
105+
} else {
106+
// Parse simple commands
107+
const groups: Group[] = [];
108+
const parsedCommands: Command[] = [];
109+
let colorIndex = 0;
110+
111+
commands.forEach((cmd) => {
112+
const [nameWithGroup, command] = cmd.split("=");
113+
const [groupName, name] = nameWithGroup.includes(":")
114+
? nameWithGroup.split(":")
115+
: [undefined, nameWithGroup];
116+
117+
if (groupName) {
118+
let group = groups.find((g) => g.name === groupName.toUpperCase());
119+
if (!group) {
120+
const color = ANSI_COLORS[colorIndex++ % ANSI_COLORS.length];
121+
group = {
122+
name: groupName.toUpperCase(),
123+
color,
124+
commands: [],
125+
};
126+
groups.push(group);
127+
}
128+
group.commands.push(name.toUpperCase());
129+
}
130+
131+
parsedCommands.push({
132+
name: name.toUpperCase(),
133+
cmd: command,
134+
color: ANSI_COLORS[colorIndex++ % ANSI_COLORS.length],
135+
group: groupName?.toUpperCase(),
136+
});
137+
});
138+
139+
config = {
140+
commands: parsedCommands,
141+
groups,
142+
options: {
143+
bufferSize: Number.parseInt(options.bufferSize, 10),
144+
logFile: options.logFile,
145+
},
146+
};
147+
}
148+
149+
// Validate config
150+
if (!config.commands?.length) {
151+
console.error(
152+
"No commands specified. Use either a config file or provide commands directly."
153+
);
154+
process.exit(1);
155+
}
156+
157+
const bufferSize = config.options?.bufferSize || Number.parseInt(options.bufferSize, 10);
158+
if (Number.isNaN(bufferSize) || bufferSize < 1) {
159+
console.error("Buffer size must be a positive number");
160+
process.exit(1);
161+
}
162+
163+
// Format log file path if logging is enabled
164+
const logFile = options.logFile
165+
? formatLogPath(options.logFile)
166+
: config.options?.logFile
167+
? formatLogPath(config.options.logFile)
168+
: null;
169+
170+
console.log("Starting processes...");
171+
const manager = new ProcessManager(config.commands, config.groups || [], bufferSize, logFile);
172+
manager.start();
173+
});
174+
175+
program.parse();

0 commit comments

Comments
 (0)