lcluster 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/README.md +173 -0
- package/built-in-templates/default.yml +14 -0
- package/built-in-templates/high-memory.yml +14 -0
- package/built-in-templates/minimal.yml +14 -0
- package/generate_logos.js +39 -0
- package/package.json +52 -0
- package/src/alerts/desktop.js +15 -0
- package/src/alerts/discord.js +53 -0
- package/src/alerts/index.js +50 -0
- package/src/alerts/sound.js +5 -0
- package/src/cli.js +25 -0
- package/src/core/config.js +17 -0
- package/src/core/events.js +5 -0
- package/src/core/healthcheck.js +126 -0
- package/src/core/loadbalancer.js +26 -0
- package/src/core/logBuffer.js +35 -0
- package/src/core/registry.js +59 -0
- package/src/gateway/guildMap.js +6 -0
- package/src/gateway/proxy.js +30 -0
- package/src/gateway/router.js +6 -0
- package/src/gateway/server.js +35 -0
- package/src/gateway/sessionMap.js +6 -0
- package/src/gateway/v4/rest.js +79 -0
- package/src/gateway/v4/websocket.js +95 -0
- package/src/main.js +133 -0
- package/src/spawner/docker.js +55 -0
- package/src/spawner/process.js +43 -0
- package/src/system/detect.js +74 -0
- package/src/system/systemd.js +33 -0
- package/src/templates/manager.js +66 -0
- package/src/templates/validator.js +36 -0
- package/src/tui/components/Border.jsx +27 -0
- package/src/tui/components/KeyHints.jsx +20 -0
- package/src/tui/components/MiniBar.jsx +22 -0
- package/src/tui/components/NodeCard.jsx +65 -0
- package/src/tui/components/NodeList.jsx +60 -0
- package/src/tui/components/StatPanel.jsx +64 -0
- package/src/tui/components/StatusDot.jsx +22 -0
- package/src/tui/index.jsx +69 -0
- package/src/tui/init/DiscordAlerts.jsx +122 -0
- package/src/tui/init/Done.jsx +77 -0
- package/src/tui/init/GatewaySetup.jsx +59 -0
- package/src/tui/init/NodeSetup.jsx +158 -0
- package/src/tui/init/ThemePicker.jsx +51 -0
- package/src/tui/init/Welcome.jsx +57 -0
- package/src/tui/init/index.jsx +78 -0
- package/src/tui/screens/Dashboard.jsx +51 -0
- package/src/tui/screens/Logs.jsx +59 -0
- package/src/tui/screens/NodeDetail.jsx +57 -0
- package/src/tui/screens/Settings.jsx +154 -0
- package/src/tui/screens/Templates.jsx +42 -0
- package/src/tui/theme/amber.js +16 -0
- package/src/tui/theme/cyberpunk.js +15 -0
- package/src/tui/theme/hacker.js +15 -0
- package/src/tui/theme/index.js +42 -0
- package/src/tui/theme/minimal.js +16 -0
- package/src/tui/theme/neon.js +16 -0
- package/src/tui/theme/ocean.js +15 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), '.lcluster');
|
|
6
|
+
const NODES_FILE = path.join(CONFIG_DIR, 'nodes.json');
|
|
7
|
+
|
|
8
|
+
let nodesArray = [];
|
|
9
|
+
|
|
10
|
+
export function loadNodes() {
|
|
11
|
+
if (fs.existsSync(NODES_FILE)) {
|
|
12
|
+
try {
|
|
13
|
+
const data = fs.readFileSync(NODES_FILE, 'utf-8');
|
|
14
|
+
nodesArray = JSON.parse(data);
|
|
15
|
+
} catch (e) {
|
|
16
|
+
nodesArray = [];
|
|
17
|
+
}
|
|
18
|
+
} else {
|
|
19
|
+
nodesArray = [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function saveNodes() {
|
|
24
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
25
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
fs.writeFileSync(NODES_FILE, JSON.stringify(nodesArray, null, 2), 'utf-8');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function addNode(node) {
|
|
31
|
+
const existingIndex = nodesArray.findIndex(n => n.name === node.name);
|
|
32
|
+
if (existingIndex !== -1) {
|
|
33
|
+
nodesArray[existingIndex] = node;
|
|
34
|
+
} else {
|
|
35
|
+
nodesArray.push(node);
|
|
36
|
+
}
|
|
37
|
+
saveNodes();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function removeNode(name) {
|
|
41
|
+
nodesArray = nodesArray.filter(n => n.name !== name);
|
|
42
|
+
saveNodes();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getNode(name) {
|
|
46
|
+
return nodesArray.find(n => n.name === name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getAllNodes() {
|
|
50
|
+
return [...nodesArray];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function updateNode(name, data) {
|
|
54
|
+
const node = getNode(name);
|
|
55
|
+
if (node) {
|
|
56
|
+
Object.assign(node, data);
|
|
57
|
+
saveNodes();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
const guildMap = new Map();
|
|
2
|
+
|
|
3
|
+
export const setGuild = (guildId, nodeId) => guildMap.set(guildId, nodeId);
|
|
4
|
+
export const getGuild = (guildId) => guildMap.get(guildId);
|
|
5
|
+
export const deleteGuild = (guildId) => guildMap.delete(guildId);
|
|
6
|
+
export const getAllGuilds = () => [...guildMap.entries()];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import httpProxy from 'http-proxy';
|
|
2
|
+
|
|
3
|
+
const proxy = httpProxy.createProxyServer({
|
|
4
|
+
ignorePath: true
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
proxy.on('error', (err, req, res) => {
|
|
8
|
+
if (res && res.writeHead) {
|
|
9
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
10
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export function handleProxy(req, res, targetUrl) {
|
|
15
|
+
// Add bot IP
|
|
16
|
+
req.headers['x-forwarded-for'] = req.socket.remoteAddress;
|
|
17
|
+
|
|
18
|
+
proxy.web(req, res, {
|
|
19
|
+
target: targetUrl
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function handleWsProxy(req, socket, head, targetUrl) {
|
|
24
|
+
// Add bot IP
|
|
25
|
+
req.headers['x-forwarded-for'] = req.socket.remoteAddress;
|
|
26
|
+
|
|
27
|
+
proxy.ws(req, socket, head, {
|
|
28
|
+
target: targetUrl
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { handleRest } from './v4/rest.js';
|
|
3
|
+
import { handleWebsocketUpgrade } from './v4/websocket.js';
|
|
4
|
+
import { getGatewayConfig } from '../core/config.js';
|
|
5
|
+
import { events } from '../core/events.js';
|
|
6
|
+
|
|
7
|
+
export function startGateway() {
|
|
8
|
+
const config = getGatewayConfig();
|
|
9
|
+
|
|
10
|
+
const server = http.createServer((req, res) => {
|
|
11
|
+
// Only route v4, drop others
|
|
12
|
+
if (req.url.startsWith('/v4/')) {
|
|
13
|
+
handleRest(req, res);
|
|
14
|
+
} else {
|
|
15
|
+
res.writeHead(404);
|
|
16
|
+
res.end('Not Found');
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
server.on('upgrade', (req, socket, head) => {
|
|
21
|
+
const path = new URL(req.url, `http://${req.headers.host}`).pathname;
|
|
22
|
+
|
|
23
|
+
if (path === '/v4/websocket') {
|
|
24
|
+
handleWebsocketUpgrade(req, socket, head, config.password);
|
|
25
|
+
} else {
|
|
26
|
+
socket.destroy();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
server.listen(config.port, () => {
|
|
31
|
+
events.emit('gateway:ready', config.port);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return server;
|
|
35
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
const sessionMap = new Map();
|
|
2
|
+
|
|
3
|
+
export const setSession = (sessionId, nodeId) => sessionMap.set(sessionId, nodeId);
|
|
4
|
+
export const getSession = (sessionId) => sessionMap.get(sessionId);
|
|
5
|
+
export const deleteSession = (sessionId) => sessionMap.delete(sessionId);
|
|
6
|
+
export const getAllSessions = () => [...sessionMap.entries()];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { pickNode } from '../../core/loadbalancer.js';
|
|
2
|
+
import { getSession } from '../sessionMap.js';
|
|
3
|
+
import { getNode, getAllNodes } from '../../core/registry.js';
|
|
4
|
+
import { handleProxy } from '../proxy.js';
|
|
5
|
+
|
|
6
|
+
export function handleRest(req, res) {
|
|
7
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
8
|
+
const path = url.pathname;
|
|
9
|
+
|
|
10
|
+
if (path === '/v4/stats') {
|
|
11
|
+
return handleStats(req, res);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Handle specific session
|
|
15
|
+
const sessionMatch = path.match(/^\/v4\/sessions\/([^\/]+)(.*)$/);
|
|
16
|
+
if (sessionMatch) {
|
|
17
|
+
const sessionId = sessionMatch[1];
|
|
18
|
+
const nodeName = getSession(sessionId);
|
|
19
|
+
if (nodeName) {
|
|
20
|
+
const targetNode = getNode(nodeName);
|
|
21
|
+
if (targetNode && targetNode.status === 'online') {
|
|
22
|
+
return handleProxy(req, res, `http://localhost:${targetNode.port}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Fallback / Load balancer
|
|
28
|
+
try {
|
|
29
|
+
const targetNode = pickNode();
|
|
30
|
+
handleProxy(req, res, `http://localhost:${targetNode.port}`);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
33
|
+
res.end(JSON.stringify({ error: 'No nodes available' }));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function handleStats(req, res) {
|
|
38
|
+
const nodes = getAllNodes().filter(n => n.status === 'online');
|
|
39
|
+
|
|
40
|
+
// Aggregate stats
|
|
41
|
+
const aggregated = {
|
|
42
|
+
players: 0,
|
|
43
|
+
playingPlayers: 0,
|
|
44
|
+
uptime: Math.floor(process.uptime() * 1000), // cluster uptime (not node uptime)
|
|
45
|
+
memory: {
|
|
46
|
+
free: 0,
|
|
47
|
+
used: 0,
|
|
48
|
+
allocated: 0,
|
|
49
|
+
reservable: 0
|
|
50
|
+
},
|
|
51
|
+
cpu: {
|
|
52
|
+
cores: 0,
|
|
53
|
+
systemLoad: 0,
|
|
54
|
+
lavalinkLoad: 0
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (nodes.length === 0) {
|
|
59
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
60
|
+
return res.end(JSON.stringify(aggregated));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Since we don't store the full stats object historically, we just return
|
|
64
|
+
// the basic summation logic over the cached node data.
|
|
65
|
+
let sumCpu = 0;
|
|
66
|
+
|
|
67
|
+
nodes.forEach(n => {
|
|
68
|
+
aggregated.players += (n.players || 0);
|
|
69
|
+
// Rough approximations since we only cache high level stats in registry
|
|
70
|
+
// In a full impl we would fetch live stats or cache full object
|
|
71
|
+
aggregated.memory.used += ((n.memory || 0) * 1024 * 1024);
|
|
72
|
+
sumCpu += (n.cpu || 0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
aggregated.cpu.lavalinkLoad = (sumCpu / nodes.length) / 100;
|
|
76
|
+
|
|
77
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
78
|
+
res.end(JSON.stringify(aggregated));
|
|
79
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
2
|
+
import { pickNode } from '../../core/loadbalancer.js';
|
|
3
|
+
import { setSession, getSession } from '../sessionMap.js';
|
|
4
|
+
import { events } from '../../core/events.js';
|
|
5
|
+
import { getNode, getAllNodes } from '../../core/registry.js';
|
|
6
|
+
|
|
7
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
8
|
+
|
|
9
|
+
export function handleWebsocketUpgrade(req, socket, head, gatewayPassword) {
|
|
10
|
+
const auth = req.headers['authorization'];
|
|
11
|
+
if (auth !== gatewayPassword) {
|
|
12
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
13
|
+
socket.destroy();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
wss.handleUpgrade(req, socket, head, (botWs) => {
|
|
18
|
+
const resumeKey = req.headers['session-resume-key'];
|
|
19
|
+
let targetNode;
|
|
20
|
+
|
|
21
|
+
if (resumeKey) {
|
|
22
|
+
const existingNodeName = getSession(resumeKey);
|
|
23
|
+
if (existingNodeName) {
|
|
24
|
+
targetNode = getNode(existingNodeName);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!targetNode || targetNode.status !== 'online') {
|
|
29
|
+
try {
|
|
30
|
+
targetNode = pickNode();
|
|
31
|
+
} catch (e) {
|
|
32
|
+
botWs.close(1011, 'No nodes available');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
connectToNode(botWs, req, targetNode, resumeKey);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function connectToNode(botWs, req, targetNode, resumeKey) {
|
|
42
|
+
const headers = { ...req.headers };
|
|
43
|
+
delete headers.host;
|
|
44
|
+
delete headers.upgrade;
|
|
45
|
+
delete headers.connection;
|
|
46
|
+
delete headers['sec-websocket-key'];
|
|
47
|
+
delete headers['sec-websocket-version'];
|
|
48
|
+
delete headers['sec-websocket-extensions'];
|
|
49
|
+
|
|
50
|
+
const nodeWs = new WebSocket(`ws://localhost:${targetNode.port}/v4/websocket`, {
|
|
51
|
+
headers: {
|
|
52
|
+
...headers,
|
|
53
|
+
'Authorization': targetNode.password || 'youshallnotpass' // Usually load config
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
let sessionId = resumeKey;
|
|
58
|
+
|
|
59
|
+
nodeWs.on('message', (data, isBinary) => {
|
|
60
|
+
// Check first message for ready op
|
|
61
|
+
if (!isBinary) {
|
|
62
|
+
try {
|
|
63
|
+
const msg = JSON.parse(data.toString());
|
|
64
|
+
if (msg.op === 'ready' && msg.sessionId) {
|
|
65
|
+
sessionId = msg.sessionId;
|
|
66
|
+
setSession(sessionId, targetNode.name);
|
|
67
|
+
events.emit('session:created', sessionId);
|
|
68
|
+
}
|
|
69
|
+
} catch (e) { }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Pipe buffer directly
|
|
73
|
+
if (botWs.readyState === WebSocket.OPEN) {
|
|
74
|
+
botWs.send(data, { binary: isBinary });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
botWs.on('message', (data, isBinary) => {
|
|
79
|
+
if (nodeWs.readyState === WebSocket.OPEN) {
|
|
80
|
+
nodeWs.send(data, { binary: isBinary });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
botWs.on('close', () => {
|
|
85
|
+
nodeWs.close();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
nodeWs.on('close', () => {
|
|
89
|
+
botWs.close();
|
|
90
|
+
// In a full implementation we'd do failover here if the bot wasn't the one who closed
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
botWs.on('error', () => nodeWs.close());
|
|
94
|
+
nodeWs.on('error', () => botWs.close());
|
|
95
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { renderTui } from './tui/index.jsx';
|
|
4
|
+
import { spawnProcess } from './spawner/process.js';
|
|
5
|
+
import { spawnDocker } from './spawner/docker.js';
|
|
6
|
+
import { initWizard } from './tui/init/index.jsx';
|
|
7
|
+
import { getAllNodes, getNode } from './core/registry.js';
|
|
8
|
+
import { loadNodes } from './core/registry.js';
|
|
9
|
+
import { initAlerts } from './alerts/index.js';
|
|
10
|
+
|
|
11
|
+
export async function runCLI() {
|
|
12
|
+
initAlerts();
|
|
13
|
+
const program = new Command();
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name('lcluster')
|
|
17
|
+
.description('⬡ lcluster — Lavalink Cluster Manager')
|
|
18
|
+
.version(`
|
|
19
|
+
⬡ lcluster v1.0.0
|
|
20
|
+
|
|
21
|
+
Built by Ram Krishna & Claude (Anthropic AI)
|
|
22
|
+
Lavalink Cluster Manager for Node.js
|
|
23
|
+
`, '-v, --version', 'output the version number')
|
|
24
|
+
.addHelpText('after', '\n Docs: https://lcluster.dev\n');
|
|
25
|
+
|
|
26
|
+
program
|
|
27
|
+
.command('ui', { isDefault: true })
|
|
28
|
+
.description('opens full TUI dashboard')
|
|
29
|
+
.action(() => {
|
|
30
|
+
renderTui();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
program
|
|
34
|
+
.command('init')
|
|
35
|
+
.description('runs the setup wizard')
|
|
36
|
+
.addHelpText('before', `
|
|
37
|
+
lcluster init — First time setup wizard
|
|
38
|
+
|
|
39
|
+
This command walks you through:
|
|
40
|
+
· Checking your system (Java, Docker, ports)
|
|
41
|
+
· Choosing a theme for the TUI
|
|
42
|
+
· Setting up the gateway (port + password)
|
|
43
|
+
· Adding your first Lavalink node
|
|
44
|
+
· Optionally installing as a systemd service (Ubuntu only)
|
|
45
|
+
|
|
46
|
+
Run this once after installing lcluster.
|
|
47
|
+
Safe to re-run — will not overwrite existing nodes without confirmation.
|
|
48
|
+
`)
|
|
49
|
+
.action(async () => {
|
|
50
|
+
await initWizard();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
program
|
|
54
|
+
.command('ps')
|
|
55
|
+
.description('prints node list to terminal, no TUI')
|
|
56
|
+
.addHelpText('before', `
|
|
57
|
+
lcluster ps — List all registered nodes
|
|
58
|
+
|
|
59
|
+
Shows a table of all nodes with:
|
|
60
|
+
· Name, template, mode, status, players, ping
|
|
61
|
+
|
|
62
|
+
Does not open the TUI. Output is plain terminal text.
|
|
63
|
+
Useful for quick checks from scripts or without a full terminal.
|
|
64
|
+
`)
|
|
65
|
+
.action(() => {
|
|
66
|
+
loadNodes();
|
|
67
|
+
const nodes = getAllNodes();
|
|
68
|
+
console.log(chalk.bold(' lcluster — node list\n'));
|
|
69
|
+
console.log(` ${'NAME'.padEnd(15)} ${'TEMPLATE'.padEnd(16)} ${'MODE'.padEnd(11)} ${'STATUS'.padEnd(14)} ${'PLAYERS'.padEnd(9)} ${'PING'}`);
|
|
70
|
+
console.log(' ' + '─'.repeat(74));
|
|
71
|
+
nodes.forEach(n => {
|
|
72
|
+
const modeStr = n.mode === 'external' ? 'external ⟳' : n.mode;
|
|
73
|
+
const templateStr = n.mode === 'external' ? '—' : (n.template || 'unknown');
|
|
74
|
+
const isOnline = n.status === 'online';
|
|
75
|
+
|
|
76
|
+
let rawStatus = n.status || 'offline';
|
|
77
|
+
let statusColor;
|
|
78
|
+
if (rawStatus === 'waiting') statusColor = chalk.gray('⟳ waiting');
|
|
79
|
+
else if (rawStatus === 'reconnecting') statusColor = chalk.yellow('⚠ reconnecting');
|
|
80
|
+
else if (rawStatus === 'unreachable') statusColor = chalk.red('✕ unreachable');
|
|
81
|
+
else if (isOnline) statusColor = chalk.green('● online');
|
|
82
|
+
else if (rawStatus === 'degraded') statusColor = chalk.yellow('⚠ warn');
|
|
83
|
+
else statusColor = chalk.red('✕ offline');
|
|
84
|
+
|
|
85
|
+
// chalk output formatting padding trick (ansi codes add ~9 chars)
|
|
86
|
+
console.log(` ${n.name.padEnd(15)} ${templateStr.padEnd(16)} ${modeStr.padEnd(11)} ${statusColor.padEnd(23)} ${String(n.players || 0).padEnd(9)} ${n.ping || 0}ms`);
|
|
87
|
+
});
|
|
88
|
+
console.log('\n');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
program
|
|
92
|
+
.command('start <name>')
|
|
93
|
+
.description('starts a stopped node')
|
|
94
|
+
.action(async (name) => {
|
|
95
|
+
console.log(`Starting ${name}...`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
program
|
|
99
|
+
.command('stop <name>')
|
|
100
|
+
.description('stops a running node')
|
|
101
|
+
.action((name) => {
|
|
102
|
+
console.log(`Stopping ${name}...`);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
program
|
|
106
|
+
.command('restart <name>')
|
|
107
|
+
.description('restarts a node')
|
|
108
|
+
.action((name) => {
|
|
109
|
+
console.log(`Restarting ${name}...`);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
program
|
|
113
|
+
.command('logs <name>')
|
|
114
|
+
.description('tails logs for that node in terminal')
|
|
115
|
+
.addHelpText('before', `
|
|
116
|
+
lcluster logs <name> — Tail logs for a specific node
|
|
117
|
+
|
|
118
|
+
Arguments:
|
|
119
|
+
name The name of the node (use lcluster ps to see names)
|
|
120
|
+
|
|
121
|
+
Streams live log output from the node to your terminal.
|
|
122
|
+
Press Ctrl+C to stop.
|
|
123
|
+
|
|
124
|
+
Example:
|
|
125
|
+
lcluster logs node-main
|
|
126
|
+
lcluster logs node-docker
|
|
127
|
+
`)
|
|
128
|
+
.action((name) => {
|
|
129
|
+
console.log(`Tailing logs for ${name}...`);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
program.parse();
|
|
133
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import Docker from 'dockerode';
|
|
2
|
+
import { events } from '../core/events.js';
|
|
3
|
+
import { updateNode } from '../core/registry.js';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
|
|
8
|
+
const docker = new Docker();
|
|
9
|
+
const CONFIG_DIR = path.join(os.homedir(), '.lcluster');
|
|
10
|
+
const NODES_DIR = path.join(CONFIG_DIR, 'nodes');
|
|
11
|
+
|
|
12
|
+
export async function spawnDocker(node) {
|
|
13
|
+
const workingDir = path.join(NODES_DIR, node.name);
|
|
14
|
+
if (!fs.existsSync(workingDir)) {
|
|
15
|
+
fs.mkdirSync(workingDir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const configPath = path.join(workingDir, 'application.yml');
|
|
19
|
+
|
|
20
|
+
const container = await docker.createContainer({
|
|
21
|
+
Image: 'fredboat/lavalink:latest',
|
|
22
|
+
name: `lcluster-${node.name}`,
|
|
23
|
+
HostConfig: {
|
|
24
|
+
PortBindings: {
|
|
25
|
+
'2333/tcp': [{ HostPort: String(node.port) }]
|
|
26
|
+
},
|
|
27
|
+
Binds: [
|
|
28
|
+
`${configPath}:/opt/Lavalink/application.yml`
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await container.start();
|
|
34
|
+
|
|
35
|
+
const logStream = await container.logs({ follow: true, stdout: true, stderr: true });
|
|
36
|
+
|
|
37
|
+
logStream.on('data', (chunk) => {
|
|
38
|
+
// Docker log stream has an 8 byte header per frame
|
|
39
|
+
// For simplicity with standard terminal output we strip it
|
|
40
|
+
// Actually we can just stringify it
|
|
41
|
+
const text = chunk.toString('utf8').replace(/^[^\x20-\x7E]+/, '');
|
|
42
|
+
const lines = text.split('\n').filter(Boolean);
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
events.emit(`log:${node.name}`, line);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
container.wait().then((data) => {
|
|
49
|
+
updateNode(node.name, { status: 'offline' });
|
|
50
|
+
events.emit('node:offline', node.name);
|
|
51
|
+
events.emit(`log:${node.name}`, `[lcluster] Container exited with code ${data.StatusCode}`);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return container;
|
|
55
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { events } from '../core/events.js';
|
|
3
|
+
import { updateNode } from '../core/registry.js';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
|
|
8
|
+
const CONFIG_DIR = path.join(os.homedir(), '.lcluster');
|
|
9
|
+
const NODES_DIR = path.join(CONFIG_DIR, 'nodes');
|
|
10
|
+
|
|
11
|
+
export function spawnProcess(node) {
|
|
12
|
+
const workingDir = path.join(NODES_DIR, node.name);
|
|
13
|
+
if (!fs.existsSync(workingDir)) {
|
|
14
|
+
fs.mkdirSync(workingDir, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const proc = spawn('java', ['-jar', 'Lavalink.jar'], {
|
|
18
|
+
cwd: workingDir,
|
|
19
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
proc.stdout.on('data', (chunk) => {
|
|
23
|
+
const lines = chunk.toString().split('\n').filter(Boolean);
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
events.emit(`log:${node.name}`, line);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
proc.stderr.on('data', (chunk) => {
|
|
30
|
+
const lines = chunk.toString().split('\n').filter(Boolean);
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
events.emit(`log:${node.name}`, line);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
proc.on('exit', (code) => {
|
|
37
|
+
updateNode(node.name, { status: 'offline' });
|
|
38
|
+
events.emit('node:offline', node.name);
|
|
39
|
+
events.emit(`log:${node.name}`, `[lcluster] Process exited with code ${code}`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return proc;
|
|
43
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
|
|
5
|
+
export async function detectSystem() {
|
|
6
|
+
const result = {
|
|
7
|
+
os: detectOs(),
|
|
8
|
+
java: detectJava(),
|
|
9
|
+
docker: detectDocker(),
|
|
10
|
+
port2333: await detectPort(2333)
|
|
11
|
+
};
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function detectOs() {
|
|
16
|
+
const platform = os.platform();
|
|
17
|
+
if (platform === 'darwin') return 'macos';
|
|
18
|
+
if (platform === 'win32') return 'windows';
|
|
19
|
+
if (platform === 'linux') {
|
|
20
|
+
try {
|
|
21
|
+
const osRelease = fs.readFileSync('/etc/os-release', 'utf8');
|
|
22
|
+
if (osRelease.includes('ubuntu')) return 'ubuntu';
|
|
23
|
+
if (osRelease.includes('debian')) return 'debian';
|
|
24
|
+
} catch {
|
|
25
|
+
return 'linux';
|
|
26
|
+
}
|
|
27
|
+
return 'linux';
|
|
28
|
+
}
|
|
29
|
+
return 'unknown';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function detectJava() {
|
|
33
|
+
const proc = spawnSync('java', ['-version'], { encoding: 'utf8' });
|
|
34
|
+
if (proc.error || proc.status !== 0) {
|
|
35
|
+
return { found: false, version: null };
|
|
36
|
+
}
|
|
37
|
+
const output = proc.stderr || proc.stdout; // Java usually prints version to stderr
|
|
38
|
+
const match = output.match(/version "([^"]+)"/);
|
|
39
|
+
return {
|
|
40
|
+
found: true,
|
|
41
|
+
version: match ? match[1] : 'unknown'
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function detectDocker() {
|
|
46
|
+
const proc = spawnSync('docker', ['info'], { encoding: 'utf8' });
|
|
47
|
+
if (proc.error) {
|
|
48
|
+
return { found: false, running: false };
|
|
49
|
+
}
|
|
50
|
+
if (proc.status !== 0) {
|
|
51
|
+
return { found: true, running: false };
|
|
52
|
+
}
|
|
53
|
+
return { found: true, running: true };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function detectPort(port) {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const server = net.createServer();
|
|
59
|
+
server.once('error', (err) => {
|
|
60
|
+
if (err.code === 'EADDRINUSE') {
|
|
61
|
+
resolve({ available: false });
|
|
62
|
+
} else {
|
|
63
|
+
resolve({ available: false });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
server.once('listening', () => {
|
|
68
|
+
server.close();
|
|
69
|
+
resolve({ available: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
server.listen(port);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export function installSystemd() {
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const cliPath = path.resolve(path.dirname(__filename), '../../src/cli.js');
|
|
9
|
+
|
|
10
|
+
const unitFile = `[Unit]
|
|
11
|
+
Description=lcluster Lavalink Cluster Manager
|
|
12
|
+
After=network.target
|
|
13
|
+
|
|
14
|
+
[Service]
|
|
15
|
+
Type=simple
|
|
16
|
+
ExecStart=${process.execPath} ${cliPath} ui
|
|
17
|
+
Restart=always
|
|
18
|
+
RestartSec=5
|
|
19
|
+
User=root
|
|
20
|
+
|
|
21
|
+
[Install]
|
|
22
|
+
WantedBy=multi-user.target
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
fs.writeFileSync('/etc/systemd/system/lcluster.service', unitFile, 'utf8');
|
|
27
|
+
spawnSync('systemctl', ['daemon-reload'], { stdio: 'ignore' });
|
|
28
|
+
spawnSync('systemctl', ['enable', 'lcluster'], { stdio: 'ignore' });
|
|
29
|
+
return true;
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|