lcluster 1.0.0 → 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.0-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.0 ● 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] ─┐
@@ -154,13 +154,6 @@ Full architectural guides, TUI maps, setup instructions, and deployment strategi
154
154
 
155
155
  ---
156
156
 
157
- ## Roadmap
158
-
159
- - [x] v1.0.0 — Core cluster manager, TUI dashboard, gateway, alerts
160
- - [ ] v1.0.1 — Custom Discord bot integration with token support
161
-
162
- ---
163
-
164
157
  ## Credits
165
158
 
166
159
  lcluster was designed and built by **Ram Krishna** with architecture,
@@ -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,42 @@
1
+ server:
2
+ port: 2333
3
+ address: 0.0.0.0
4
+
5
+ lavalink:
6
+ server:
7
+ password: "youshallnotpass"
8
+ sources:
9
+ youtube: true
10
+ bandcamp: true
11
+ soundcloud: true
12
+ twitch: true
13
+ vimeo: true
14
+ mixer: true
15
+ http: true
16
+ local: false
17
+ filters:
18
+ volume: true
19
+ equalizer: true
20
+ karaoke: true
21
+ timescale: true
22
+ tremolo: true
23
+ vibrato: true
24
+ distortion: true
25
+ rotation: true
26
+ channelMix: true
27
+ lowPass: true
28
+ bufferDurationMs: 400
29
+ youtubePlaylistLoadLimit: 6
30
+ playerUpdateInterval: 5
31
+ youtubeSearchEnabled: true
32
+ soundcloudSearchEnabled: true
33
+ gc-warnings: true
34
+
35
+ metrics:
36
+ prometheus:
37
+ enabled: false
38
+ endpoint: /metrics
39
+
40
+ sentry:
41
+ dsn: ""
42
+ environment: ""
@@ -0,0 +1,31 @@
1
+ server:
2
+ port: 2333
3
+ address: 0.0.0.0
4
+
5
+ lavalink:
6
+ server:
7
+ password: "youshallnotpass"
8
+ sources:
9
+ youtube: true
10
+ bandcamp: true
11
+ soundcloud: true
12
+ twitch: true
13
+ vimeo: true
14
+ mixer: true
15
+ http: true
16
+ local: false
17
+ bufferDurationMs: 400
18
+ youtubePlaylistLoadLimit: 6
19
+ playerUpdateInterval: 5
20
+ youtubeSearchEnabled: true
21
+ soundcloudSearchEnabled: true
22
+ gc-warnings: true
23
+
24
+ metrics:
25
+ prometheus:
26
+ enabled: false
27
+ endpoint: /metrics
28
+
29
+ sentry:
30
+ dsn: ""
31
+ environment: ""
@@ -0,0 +1,31 @@
1
+ server:
2
+ port: 2333
3
+ address: 0.0.0.0
4
+
5
+ lavalink:
6
+ server:
7
+ password: "youshallnotpass"
8
+ sources:
9
+ youtube: true
10
+ bandcamp: false
11
+ soundcloud: false
12
+ twitch: false
13
+ vimeo: false
14
+ mixer: false
15
+ http: false
16
+ local: false
17
+ bufferDurationMs: 400
18
+ youtubePlaylistLoadLimit: 6
19
+ playerUpdateInterval: 5
20
+ youtubeSearchEnabled: true
21
+ soundcloudSearchEnabled: true
22
+ gc-warnings: true
23
+
24
+ metrics:
25
+ prometheus:
26
+ enabled: false
27
+ endpoint: /metrics
28
+
29
+ sentry:
30
+ dsn: ""
31
+ environment: ""
@@ -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.0",
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
@@ -10,7 +10,7 @@ if (!process.env.LCLUSTER_TSX_BOOTSTRAPPED) {
10
10
  const result = spawnSync(process.execPath, [
11
11
  '--no-warnings',
12
12
  '--import',
13
- 'tsx',
13
+ import.meta.resolve('tsx'),
14
14
  __filename,
15
15
  ...process.argv.slice(2)
16
16
  ], {
@@ -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.0
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.0</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');
@@ -86,8 +94,7 @@ export default function Settings({ onBack }) {
86
94
  <Box flexDirection="column" width="100%" height="100%" backgroundColor={theme.background}>
87
95
  <Border title="settings" borderColor={theme.border} flexGrow={1}>
88
96
  <Box flexDirection="column" paddingX={1} marginY={1}>
89
- <Text color={theme.textDim}>APPEARANCE</Text>
90
- </Box>
97
+ <Text color={theme.textDim}>APPEARANCE</Text>
91
98
  <Box marginBottom={1}>
92
99
  <Text color={theme.textDim}>Theme </Text>
93
100
  <Text color={field === 0 ? theme.text : theme.textDim}>
@@ -136,19 +143,33 @@ export default function Settings({ onBack }) {
136
143
  <TextInput value={cpuWarn} onChange={setCpuWarn} focus={field === 7} />
137
144
  <Text color={theme.textDim}>%</Text>
138
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>
139
160
  </Box>
140
161
  <Box flexDirection="column" paddingX={1} marginTop={1}>
141
162
  <Text color={theme.borderDim}>{'─'.repeat(45)}</Text>
142
163
  <Box marginTop={1} flexDirection="column">
143
- <Text color={theme.text} bold>lcluster v1.0.0</Text>
164
+ <Text color={theme.text} bold>lcluster v1.0.2</Text>
144
165
  <Text color={theme.textDim}>Built by <Text color={theme.text}>Ram Krishna</Text> & <Text color={theme.text}>Claude (Anthropic AI)</Text></Text>
145
166
  <Text color={theme.textDim}>This project was designed and built with the help of AI.</Text>
146
167
  </Box>
147
168
  </Box>
148
169
  </Border >
149
- <Box marginTop={1}>
150
- <Text color={theme.textDim}>[↑↓] fields [←→] change theme [enter] save [q/esc] cancel</Text>
151
- </Box>
170
+ <Box marginTop={1}>
171
+ <Text color={theme.textDim}>[↑↓] fields [←→] change theme [enter] save [q/esc] cancel</Text>
172
+ </Box>
152
173
  </Box >
153
174
  );
154
175
  }
@@ -2,18 +2,70 @@ import React, { useState } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import { getTheme } from '../theme/index.js';
4
4
  import Border from '../components/Border.jsx';
5
- import { listTemplates } from '../../templates/manager.js';
5
+ import { listTemplates, deleteTemplate, saveTemplate } from '../../templates/manager.js';
6
+ import TextInput from 'ink-text-input';
6
7
 
7
8
  export default function Templates({ onBack }) {
8
9
  const theme = getTheme();
9
- const [templates] = useState(() => listTemplates());
10
+ const [templates, setTemplates] = useState(() => listTemplates());
10
11
  const [selectedIndex, setSelectedIndex] = useState(0);
12
+ const [showCreate, setShowCreate] = useState(false);
13
+ const [newName, setNewName] = useState('');
14
+ const [error, setError] = useState('');
15
+
16
+ const refresh = () => setTemplates(listTemplates());
11
17
 
12
18
  useInput((input, key) => {
19
+ if (showCreate) {
20
+ if (key.escape) {
21
+ setShowCreate(false);
22
+ setNewName('');
23
+ setError('');
24
+ }
25
+ if (key.return) {
26
+ if (!newName || newName.length < 2) {
27
+ setError('Name too short');
28
+ return;
29
+ }
30
+ const finalName = newName.endsWith('.yml') ? newName : newName + '.yml';
31
+ if (templates.find(t => t.name === finalName)) {
32
+ setError('Template already exists');
33
+ return;
34
+ }
35
+
36
+ // Create default baseline
37
+ const baseContent = `server:\n port: 2333\n address: 0.0.0.0\n\nlavalink:\n server:\n password: "youshallnotpass"\n sources:\n youtube: true\n`;
38
+ saveTemplate(finalName, baseContent);
39
+ setShowCreate(false);
40
+ setNewName('');
41
+ setError('');
42
+ refresh();
43
+ setSelectedIndex(templates.length); // point back to add new button usually
44
+ }
45
+ return;
46
+ }
47
+
13
48
  if (key.upArrow) setSelectedIndex(Math.max(0, selectedIndex - 1));
14
49
  if (key.downArrow) setSelectedIndex(Math.min(templates.length, selectedIndex + 1));
15
50
  if (input === 'q' || key.escape) onBack();
16
- // In full impl [enter] would open editor or delete
51
+
52
+ // Delete template
53
+ if (input === 'd' && selectedIndex < templates.length) {
54
+ const t = templates[selectedIndex];
55
+ if (t.builtin) {
56
+ setError('Cannot delete built-in template');
57
+ setTimeout(() => setError(''), 2000);
58
+ } else {
59
+ deleteTemplate(t.name);
60
+ setSelectedIndex(Math.max(0, selectedIndex - 1));
61
+ refresh();
62
+ }
63
+ }
64
+
65
+ // Add new
66
+ if (key.return && selectedIndex === templates.length) {
67
+ setShowCreate(true);
68
+ }
17
69
  });
18
70
 
19
71
  return (
@@ -21,21 +73,45 @@ export default function Templates({ onBack }) {
21
73
  <Border title="templates" borderColor={theme.border} flexGrow={1}>
22
74
  <Box flexDirection="column" paddingX={1} flexGrow={1}>
23
75
  {templates.map((t, i) => (
24
- <Text key={t.name} color={selectedIndex === i ? theme.text : theme.textDim} bold={selectedIndex === i}>
25
- {selectedIndex === i ? <Text color={theme.accent}>▶ </Text> : ' '}
76
+ <Text key={t.name} color={selectedIndex === i && !showCreate ? theme.text : theme.textDim} bold={selectedIndex === i && !showCreate}>
77
+ {selectedIndex === i && !showCreate ? <Text color={theme.accent}>▶ </Text> : ' '}
26
78
  {t.name.padEnd(20)} {t.builtin && <Text color={theme.textDim}>(built-in)</Text>}
27
79
  </Text>
28
80
  ))}
29
- <Box marginTop={1}>
30
- <Text color={selectedIndex === templates.length ? theme.text : theme.textDim} bold={selectedIndex === templates.length}>
31
- {selectedIndex === templates.length ? <Text color={theme.accent}>▶ </Text> : ' '}
32
- + Add new template
33
- </Text>
34
- </Box>
81
+
82
+ {!showCreate && (
83
+ <Box marginTop={1}>
84
+ <Text color={selectedIndex === templates.length ? theme.text : theme.textDim} bold={selectedIndex === templates.length}>
85
+ {selectedIndex === templates.length ? <Text color={theme.accent}>▶ </Text> : ' '}
86
+ + Add new template
87
+ </Text>
88
+ </Box>
89
+ )}
90
+
91
+ {showCreate && (
92
+ <Box marginTop={1} flexDirection="column">
93
+ <Box>
94
+ <Text color={theme.accent}>▶ </Text>
95
+ <Text color={theme.text}>Name: </Text>
96
+ <TextInput value={newName} onChange={setNewName} focus={showCreate} />
97
+ </Box>
98
+ <Text color={theme.textDim}>Press [Enter] to create, [Esc] to cancel. Edit manually in ~/.lcluster/templates</Text>
99
+ </Box>
100
+ )}
101
+
102
+ {error && (
103
+ <Box marginTop={1}>
104
+ <Text color={theme.error}>⚠ {error}</Text>
105
+ </Box>
106
+ )}
35
107
  </Box>
36
108
  </Border>
37
109
  <Box marginTop={1}>
38
- <Text color={theme.textDim}>[↑↓] navigate [enter] select / edit [d] delete (user only) [q] back</Text>
110
+ {showCreate ? (
111
+ <Text color={theme.textDim}>[enter] create [esc] cancel</Text>
112
+ ) : (
113
+ <Text color={theme.textDim}>[↑↓] navigate [enter] create new [d] delete (user only) [q] back</Text>
114
+ )}
39
115
  </Box>
40
116
  </Box>
41
117
  );
@@ -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;