lcluster 1.0.1 → 1.0.2

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 CHANGED
@@ -4,7 +4,7 @@
4
4
  <p>A powerful Lavalink cluster manager for your terminal.</p>
5
5
 
6
6
  <p>
7
- <img src="https://img.shields.io/badge/version-1.0.1-blue" />
7
+ <img src="https://img.shields.io/badge/version-1.0.2-blue" />
8
8
  <img src="https://img.shields.io/badge/node-%3E%3D18.0.0-green" />
9
9
  <img src="https://img.shields.io/badge/license-GPLv3-purple" />
10
10
  <img src="https://img.shields.io/badge/built%20with-Claude%20AI-orange" />
@@ -111,7 +111,7 @@ Lavalink v4 compatible client.
111
111
 
112
112
  ```
113
113
  ╔═════════════════════════════════════════════════════════════════════╗
114
- ║ ⬡ lcluster v1.0.1 ● 2 online ⚠ 1 warn gateway :2333 ●
114
+ ║ ⬡ lcluster v1.0.2 ● 2 online ⚠ 1 warn gateway :2333 ●
115
115
  ╚═════════════════════════════════════════════════════════════════════╝
116
116
 
117
117
  ┌─ nodes (3/5) ──────────────────────────────────── [↑↓ scroll] ─┐
@@ -0,0 +1,79 @@
1
+ import { Client, GatewayIntentBits } from 'discord.js';
2
+ import { LavalinkManager } from 'lavalink-client';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import yaml from 'js-yaml';
7
+
8
+ // 1. Token passed from lcluster bot manager securely
9
+ const DISCORD_TOKEN = process.env.DISCORD_TOKEN || 'YOUR_DISCORD_BOT_TOKEN';
10
+
11
+ // 2. Read lcluster internal port/password config directly to avoid desync
12
+ const configPath = path.join(os.homedir(), '.lcluster', 'config.yml');
13
+ let lclusterPassword = 'youshallnotpass';
14
+ let lclusterPort = 2333;
15
+ if (fs.existsSync(configPath)) {
16
+ try {
17
+ const config = yaml.load(fs.readFileSync(configPath, 'utf8')) || {};
18
+ lclusterPassword = config.gateway?.password || 'youshallnotpass';
19
+ lclusterPort = config.gateway?.port || 2333;
20
+ } catch { }
21
+ }
22
+
23
+ const LCLUSTER_NODE = {
24
+ id: "lcluster-gateway",
25
+ host: "localhost", // or your VPS IP
26
+ port: lclusterPort, // Dynamically synced from config
27
+ password: lclusterPassword, // Dynamically synced from config
28
+ secure: false // Set to true if behind HTTPS/WSS
29
+ };
30
+
31
+ const client = new Client({
32
+ intents: [
33
+ GatewayIntentBits.Guilds,
34
+ GatewayIntentBits.GuildMessages,
35
+ GatewayIntentBits.GuildVoiceStates,
36
+ GatewayIntentBits.MessageContent
37
+ ]
38
+ });
39
+
40
+ // Initialize lavalink-client manager pointing exactly to lcluster
41
+ const lavalink = new LavalinkManager({
42
+ nodes: [LCLUSTER_NODE],
43
+ sendToShard: (guildId, payload) => {
44
+ client.guilds.cache.get(guildId)?.shard?.send(payload);
45
+ },
46
+ client: {
47
+ id: "CLIENT_ID_PLACEHOLDER", // Replaced on login
48
+ username: "Lcluster Bot"
49
+ }
50
+ });
51
+
52
+ lavalink.nodeManager.on("connect", (node) => {
53
+ console.log(`[Lcluster] Successfully connected to gateway node: ${node.id}`);
54
+ });
55
+
56
+ lavalink.nodeManager.on("error", (node, error) => {
57
+ console.error(`[Lcluster] Error on node ${node.id}:`, error.message);
58
+ });
59
+
60
+ client.on('ready', () => {
61
+ console.log(`[Discord] Logged in as ${client.user.tag}`);
62
+ lavalink.client.id = client.user.id;
63
+ lavalink.init({ ...client.user });
64
+ });
65
+
66
+ client.on('messageCreate', async (message) => {
67
+ if (message.author.bot || !message.guild) return;
68
+
69
+ // Simple ping command
70
+ if (message.content === '!ping') {
71
+ message.reply('Pong! Cluster is active.');
72
+ }
73
+ });
74
+
75
+ // Forward raw discord voice events to lavalink
76
+ client.on("raw", (d) => lavalink.sendRawData(d));
77
+
78
+ console.log("Starting Lcluster Discord Bot...");
79
+ client.login(DISCORD_TOKEN);
@@ -0,0 +1,24 @@
1
+ import js from "@eslint/js";
2
+
3
+ export default [
4
+ js.configs.recommended,
5
+ {
6
+ languageOptions: {
7
+ ecmaVersion: 2024,
8
+ sourceType: "module",
9
+ globals: {
10
+ process: "readonly",
11
+ console: "readonly",
12
+ __dirname: "readonly",
13
+ setTimeout: "readonly",
14
+ setInterval: "readonly",
15
+ clearInterval: "readonly"
16
+ }
17
+ },
18
+ rules: {
19
+ "no-unused-vars": "warn",
20
+ "no-undef": "warn",
21
+ "no-empty": "warn"
22
+ }
23
+ }
24
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lcluster",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "A powerful Lavalink cluster manager for your terminal",
5
5
  "main": "src/cli.js",
6
6
  "type": "module",
@@ -47,6 +47,7 @@
47
47
  "@babel/core": "^7.24.0",
48
48
  "@babel/preset-env": "^7.24.0",
49
49
  "@babel/preset-react": "^7.23.3",
50
- "@babel/register": "^7.23.7"
50
+ "@babel/register": "^7.23.7",
51
+ "@eslint/js": "^10.0.1"
51
52
  }
52
- }
53
+ }
@@ -0,0 +1,46 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { fork } from 'node:child_process';
5
+ import { events } from '../core/events.js';
6
+
7
+ let botProcess = null;
8
+
9
+ export async function startBot() {
10
+ const configPath = path.join(os.homedir(), '.lcluster', 'config.yml');
11
+
12
+ let config = {};
13
+ if (fs.existsSync(configPath)) {
14
+ try {
15
+ const yaml = await import('js-yaml');
16
+ config = yaml.default.load(fs.readFileSync(configPath, 'utf8')) || {};
17
+ } catch { }
18
+ }
19
+
20
+ if (config?.bot?.enabled && config?.bot?.token) {
21
+ // Resolve absolute path to bots/template.js from local installation or global
22
+ const __dirname = path.dirname(new URL(import.meta.url).pathname);
23
+ const templatePath = path.join(__dirname, '..', '..', 'bots', 'template.js');
24
+
25
+ if (fs.existsSync(templatePath)) {
26
+ botProcess = fork(templatePath, [], {
27
+ env: { ...process.env, DISCORD_TOKEN: config.bot.token },
28
+ stdio: 'ignore' // We don't want the bot destroying our TUI with console.log
29
+ });
30
+
31
+ botProcess.on('error', (err) => {
32
+ events.emit('system:log', `Failed to start Custom Discord Bot: ${err.message}`);
33
+ });
34
+
35
+ botProcess.on('exit', (code) => {
36
+ events.emit('system:log', `Custom Discord Bot naturally exited with code ${code}`);
37
+ botProcess = null;
38
+ });
39
+
40
+ // Assume started successfully if we made it here
41
+ events.emit('system:log', 'Custom Discord Bot successfully mounted and detached to background.');
42
+ } else {
43
+ events.emit('system:log', 'Custom Discord Bot enabled but bots/template.js is missing.');
44
+ }
45
+ }
46
+ }
package/src/cli.js CHANGED
@@ -21,5 +21,22 @@ if (!process.env.LCLUSTER_TSX_BOOTSTRAPPED) {
21
21
  }
22
22
 
23
23
  // Now we can safely import everything else because tsx loader is active
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '../package.json');
27
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
28
+
29
+ // Beta Version Lockdown (Phase 10)
30
+ if (pkg.version.includes('-beta')) {
31
+ // Determine if running from global node_modules by checking the path structure
32
+ if (pkgPath.includes('node_modules') || process.execPath === process.argv[1]) {
33
+ console.error('\n[Lcluster Error] Beta versions (v1.0.2-beta) cannot be run globally.\n');
34
+ console.error('Please clone the repository and run locally via:');
35
+ console.error(' git clone https://github.com/ramkrishna-js/lcluster.git');
36
+ console.error(' cd lcluster && npm install && npm link\n');
37
+ process.exit(1);
38
+ }
39
+ }
40
+
24
41
  const { runCLI } = await import('./main.js');
25
42
  await runCLI();
@@ -12,6 +12,8 @@ export function handleRest(req, res) {
12
12
  }
13
13
 
14
14
  // Handle specific session
15
+ // eslint-disable-next-line no-useless-escape
16
+ const isSessionUpdate = req.method === 'PATCH' && /^\/v4\/sessions\/[a-zA-Z0-9]+$/.test(req.url);
15
17
  const sessionMatch = path.match(/^\/v4\/sessions\/([^\/]+)(.*)$/);
16
18
  if (sessionMatch) {
17
19
  const sessionId = sessionMatch[1];
package/src/main.js CHANGED
@@ -16,7 +16,7 @@ export async function runCLI() {
16
16
  .name('lcluster')
17
17
  .description('⬡ lcluster — Lavalink Cluster Manager')
18
18
  .version(`
19
- ⬡ lcluster v1.0.1
19
+ ⬡ lcluster v1.0.2
20
20
 
21
21
  Built by Ram Krishna & Claude (Anthropic AI)
22
22
  Lavalink Cluster Manager for Node.js
@@ -129,5 +129,44 @@ export async function runCLI() {
129
129
  console.log(`Tailing logs for ${name}...`);
130
130
  });
131
131
 
132
+ program
133
+ .command('theme')
134
+ .description('generates a custom.json theme baseline')
135
+ .action(async () => {
136
+ const fs = await import('node:fs');
137
+ const path = await import('node:path');
138
+ const os = await import('node:os');
139
+ const customPath = path.join(os.homedir(), '.lcluster', 'custom.json');
140
+
141
+ const boilerplate = {
142
+ "border": "#00ff9f",
143
+ "borderDim": "#00ff9f33",
144
+ "text": "#00ff9f",
145
+ "textDim": "#00ff9f66",
146
+ "background": "#080b14",
147
+ "selected": "#00ff9f11",
148
+ "selectedBorder": "#00ff9f55",
149
+ "online": "#00ff9f",
150
+ "degraded": "#ffcc00",
151
+ "offline": "#ff3366",
152
+ "docker": "#4facfe",
153
+ "process": "#a8edea",
154
+ "accent": "#00ff9f",
155
+ "keyHint": "#00ff9f33"
156
+ };
157
+
158
+ if (fs.existsSync(customPath)) {
159
+ console.log(chalk.yellow(`\n⚠ A custom theme already exists at: ${customPath}`));
160
+ console.log('Modify the file directly to continue customizing your TUI!');
161
+ } else {
162
+ fs.mkdirSync(path.join(os.homedir(), '.lcluster'), { recursive: true });
163
+ fs.writeFileSync(customPath, JSON.stringify(boilerplate, null, 4), 'utf8');
164
+ console.log(chalk.green(`\n✔ Custom Theme built successfully at: ${customPath}`));
165
+ console.log(chalk.cyan('\n1. Edit the hex colors inside custom.json'));
166
+ console.log(chalk.cyan('2. Open the TUI Dashboard (`lcluster`)'));
167
+ console.log(chalk.cyan('3. Press [q] then Settings and select the "< custom >" theme limit!\n'));
168
+ }
169
+ });
170
+
132
171
  program.parse();
133
172
  }
@@ -1,4 +1,5 @@
1
1
  import os from 'node:os';
2
+ import fs from 'node:fs';
2
3
  import { spawnSync } from 'node:child_process';
3
4
  import net from 'node:net';
4
5
 
@@ -49,7 +49,9 @@ export default function NodeCard({ node, isSelected }) {
49
49
  <Text color={modeColor}>{node.mode} {modeIcon}</Text>
50
50
  </Box>
51
51
  <Box>
52
- <StatusDot status={statusText} /> <Text color={statusColor}>{statusText}</Text>
52
+ <Text color={statusColor}>
53
+ <StatusDot status={statusText} /> {statusText}
54
+ </Text>
53
55
  </Box>
54
56
  </Box>
55
57
 
@@ -9,7 +9,8 @@ const themes = [
9
9
  { id: 'amber', name: '3. Retro Amber', desc: 'old school CRT terminal feel' },
10
10
  { id: 'cyberpunk', name: '4. Cyberpunk', desc: 'neon pink and cyan highlights' },
11
11
  { id: 'hacker', name: '5. Hacker', desc: 'matrix style green on black' },
12
- { id: 'ocean', name: '6. Deep Ocean', desc: 'calming deep blues and seafoam' }
12
+ { id: 'ocean', name: '6. Deep Ocean', desc: 'calming deep blues and seafoam' },
13
+ { id: 'custom', name: '7. Custom JSON', desc: 'loads ~/.lcluster/custom.json' }
13
14
  ];
14
15
 
15
16
  export default function ThemePicker({ onNext, updateConfig }) {
@@ -17,7 +17,7 @@ export default function Dashboard({ nodes, gatewayPort, uptimeStart, onAction })
17
17
  <Box flexDirection="column" paddingX={2} paddingY={1} width="100%" height="100%" backgroundColor={theme.background}>
18
18
  <Border borderColor={theme.border}>
19
19
  <Box flexDirection="row" justifyContent="space-between" width="100%">
20
- <Text color={theme.accent}> ⬡ lcluster v1.0.1</Text>
20
+ <Text color={theme.accent}> ⬡ lcluster v1.0.2</Text>
21
21
  <Box flexDirection="row" gap={2}>
22
22
  <Text color={theme.online}>● {nodes.filter(n => n.status === 'online').length} online</Text>
23
23
  <Text color={theme.degraded}>⚠ {nodes.filter(n => n.status === 'degraded' || n.status === 'reconnecting').length} warn</Text>
@@ -8,7 +8,7 @@ import { getTheme, updateTheme } from '../theme/index.js';
8
8
  import Border from '../components/Border.jsx';
9
9
  import TextInput from 'ink-text-input';
10
10
 
11
- const themes = ['neon', 'minimal', 'amber', 'cyberpunk', 'hacker', 'ocean'];
11
+ const themes = ['neon', 'minimal', 'amber', 'cyberpunk', 'hacker', 'ocean', 'custom'];
12
12
 
13
13
  export default function Settings({ onBack }) {
14
14
  const configPath = path.join(os.homedir(), '.lcluster', 'config.yml');
@@ -32,7 +32,10 @@ export default function Settings({ onBack }) {
32
32
  const [soundEnabled, setSoundEnabled] = useState(config.alerts?.sound?.enabled || false);
33
33
  const [cpuWarn, setCpuWarn] = useState(String(config.alerts?.thresholds?.cpu_warn || 90));
34
34
 
35
- const [field, setField] = useState(0); // 0..7
35
+ const [botEnabled, setBotEnabled] = useState(config.bot?.enabled || false);
36
+ const [botToken, setBotToken] = useState(config.bot?.token || '');
37
+
38
+ const [field, setField] = useState(0); // 0..9
36
39
 
37
40
  const theme = getTheme();
38
41
 
@@ -41,7 +44,7 @@ export default function Settings({ onBack }) {
41
44
  onBack();
42
45
  }
43
46
  if (key.upArrow) setField(Math.max(0, field - 1));
44
- if (key.downArrow) setField(Math.min(7, field + 1));
47
+ if (key.downArrow) setField(Math.min(9, field + 1));
45
48
 
46
49
  if (field === 0) {
47
50
  if (key.leftArrow) {
@@ -60,6 +63,7 @@ export default function Settings({ onBack }) {
60
63
  if (field === 3) setDiscordEnabled(!discordEnabled);
61
64
  else if (field === 5) setDesktopEnabled(!desktopEnabled);
62
65
  else if (field === 6) setSoundEnabled(!soundEnabled);
66
+ else if (field === 8) setBotEnabled(!botEnabled);
63
67
  else {
64
68
  // Save config
65
69
  const newConfig = {
@@ -74,6 +78,10 @@ export default function Settings({ onBack }) {
74
78
  desktop: { enabled: desktopEnabled },
75
79
  sound: { enabled: soundEnabled },
76
80
  thresholds: { cpu_warn: parseInt(cpuWarn) || 90, idle_warn: false }
81
+ },
82
+ bot: {
83
+ enabled: botEnabled,
84
+ token: botToken
77
85
  }
78
86
  };
79
87
  fs.writeFileSync(configPath, yaml.dump(newConfig), 'utf8');
@@ -135,11 +143,25 @@ export default function Settings({ onBack }) {
135
143
  <TextInput value={cpuWarn} onChange={setCpuWarn} focus={field === 7} />
136
144
  <Text color={theme.textDim}>%</Text>
137
145
  </Box>
146
+
147
+ <Box marginTop={1}>
148
+ <Text color={theme.textDim}>CUSTOM BOT (v1.0.2)</Text>
149
+ </Box>
150
+ <Box>
151
+ <Text color={theme.textDim}>Active </Text>
152
+ {field === 8 ? <Text color={theme.accent}>▶ </Text> : <Text> </Text>}
153
+ <Text color={botEnabled ? theme.online : theme.textDim}>{botEnabled ? '● enabled' : '○ disabled'}</Text>
154
+ </Box>
155
+ <Box>
156
+ <Text color={theme.textDim}>Discord Token </Text>
157
+ {field === 9 ? <Text color={theme.accent}>▶ </Text> : <Text> </Text>}
158
+ <TextInput value={botToken} onChange={setBotToken} focus={field === 9} mask="•" />
159
+ </Box>
138
160
  </Box>
139
161
  <Box flexDirection="column" paddingX={1} marginTop={1}>
140
162
  <Text color={theme.borderDim}>{'─'.repeat(45)}</Text>
141
163
  <Box marginTop={1} flexDirection="column">
142
- <Text color={theme.text} bold>lcluster v1.0.1</Text>
164
+ <Text color={theme.text} bold>lcluster v1.0.2</Text>
143
165
  <Text color={theme.textDim}>Built by <Text color={theme.text}>Ram Krishna</Text> & <Text color={theme.text}>Claude (Anthropic AI)</Text></Text>
144
166
  <Text color={theme.textDim}>This project was designed and built with the help of AI.</Text>
145
167
  </Box>
@@ -10,6 +10,7 @@ import hacker from './hacker.js';
10
10
  import ocean from './ocean.js';
11
11
 
12
12
  let currentThemeConfig = 'neon';
13
+ let customThemeCache = null;
13
14
 
14
15
  export function loadTheme() {
15
16
  const configPath = path.join(os.homedir(), '.lcluster', 'config.yml');
@@ -28,6 +29,22 @@ export function updateTheme(name) {
28
29
  }
29
30
 
30
31
  export function getTheme() {
32
+ if (currentThemeConfig === 'custom') {
33
+ if (!customThemeCache) {
34
+ const customPath = path.join(os.homedir(), '.lcluster', 'custom.json');
35
+ if (fs.existsSync(customPath)) {
36
+ try {
37
+ customThemeCache = JSON.parse(fs.readFileSync(customPath, 'utf8'));
38
+ } catch {
39
+ customThemeCache = neon; // Fallback bad json
40
+ }
41
+ } else {
42
+ customThemeCache = neon;
43
+ }
44
+ }
45
+ return customThemeCache;
46
+ }
47
+
31
48
  switch (currentThemeConfig) {
32
49
  case 'minimal': return minimal;
33
50
  case 'amber': return amber;