@bruggmann._/ccli 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 ADDED
@@ -0,0 +1,64 @@
1
+ # ccli — Chat Command Line Interface
2
+
3
+ A terminal-based real-time chat built with TypeScript and WebSockets.
4
+
5
+ ## Features
6
+
7
+ - Real-time messaging via WebSockets
8
+ - Multiple channels with persistent message history
9
+ - Live nickname changes
10
+ - Interactive menu with ASCII art
11
+ - Input validation with Zod
12
+
13
+ ## Project Structure
14
+
15
+ ```
16
+ packages/
17
+ shared/ → Schemas, types, and utilities shared between client and server
18
+ server/ → WebSocket server (state management, command handling)
19
+ client/ → Terminal client (UI, menus, chat prompt)
20
+ ```
21
+
22
+ ## Getting Started
23
+
24
+ ### Prerequisites
25
+
26
+ - Node.js
27
+ - pnpm (or npm, yarn, etc.)
28
+
29
+ ### Install
30
+
31
+ ```bash
32
+ pnpm install
33
+ ```
34
+
35
+ ### Run
36
+
37
+ Start the server:
38
+
39
+ ```bash
40
+ pnpm server
41
+ ```
42
+
43
+ In another terminal, start one or more clients:
44
+
45
+ ```bash
46
+ pnpm client
47
+ ```
48
+
49
+ ## Chat Commands
50
+
51
+ | Command | Description |
52
+ | ------------------ | ------------------------------------------------ |
53
+ | `/home` | Leave the current channel and return to the menu |
54
+ | `/join <channel>` | Join another channel (creates it if needed) |
55
+ | `/nick <nickname>` | Change your nickname |
56
+ | `/exit` | Exit the program |
57
+
58
+ ## Tech Stack
59
+
60
+ - **TypeScript** — Language
61
+ - **ws** — WebSocket server and client
62
+ - **Zod** — Schema validation
63
+ - **@inquirer/prompts** — Interactive terminal prompts
64
+ - **nodemon** — Dev server auto-restart
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createChatPrompt = createChatPrompt;
7
+ const readline_1 = __importDefault(require("readline"));
8
+ function createChatPrompt() {
9
+ const rl = readline_1.default.createInterface({
10
+ input: process.stdin,
11
+ output: process.stdout,
12
+ terminal: true,
13
+ });
14
+ let currentPrompt = '';
15
+ const setPrompt = (prompt) => {
16
+ currentPrompt = prompt.endsWith(' ') ? prompt : prompt + ' ';
17
+ rl.setPrompt(currentPrompt);
18
+ };
19
+ const readLine = (prompt) => {
20
+ setPrompt(prompt);
21
+ readline_1.default.clearLine(process.stdout, 0);
22
+ readline_1.default.cursorTo(process.stdout, 0);
23
+ process.stdout.write(currentPrompt);
24
+ return new Promise(resolve => {
25
+ rl.once('line', line => resolve(line.trim()));
26
+ });
27
+ };
28
+ const printLine = (line) => {
29
+ readline_1.default.clearLine(process.stdout, 0);
30
+ readline_1.default.cursorTo(process.stdout, 0);
31
+ process.stdout.write(line + '\n');
32
+ rl.setPrompt(currentPrompt);
33
+ rl.prompt();
34
+ };
35
+ const updatePrompt = (prompt) => {
36
+ setPrompt(prompt);
37
+ readline_1.default.clearLine(process.stdout, 0);
38
+ readline_1.default.cursorTo(process.stdout, 0);
39
+ rl.prompt();
40
+ };
41
+ const close = () => {
42
+ process.stdin.removeAllListeners('keypress');
43
+ process.stdin.resume();
44
+ };
45
+ return { readLine, printLine, updatePrompt, close };
46
+ }
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assignColor = assignColor;
4
+ exports.colorize = colorize;
5
+ exports.resetColors = resetColors;
6
+ const ESC = '\x1b[';
7
+ const RESET = `${ESC}0m`;
8
+ const PALETTE = [
9
+ `${ESC}33m`, // yellow
10
+ `${ESC}35m`, // magenta
11
+ `${ESC}32m`, // green
12
+ `${ESC}91m`, // bright red
13
+ `${ESC}94m`, // bright blue
14
+ `${ESC}93m`, // bright yellow
15
+ `${ESC}95m`, // bright magenta
16
+ `${ESC}92m`, // bright green
17
+ ];
18
+ const nickColors = new Map();
19
+ let nextIndex = 0;
20
+ function nextColor() {
21
+ const color = PALETTE[nextIndex];
22
+ nextIndex = (nextIndex + 1) % PALETTE.length;
23
+ return color;
24
+ }
25
+ function assignColor(nickname) {
26
+ if (!nickColors.has(nickname)) {
27
+ nickColors.set(nickname, nextColor());
28
+ }
29
+ }
30
+ function colorize(nickname) {
31
+ const color = nickColors.get(nickname) ?? nextColor();
32
+ if (!nickColors.has(nickname)) {
33
+ nickColors.set(nickname, color);
34
+ }
35
+ return `${color}${nickname}${RESET}`;
36
+ }
37
+ function resetColors() {
38
+ nickColors.clear();
39
+ nextIndex = 0;
40
+ }
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const socket_1 = require("./socket");
5
+ const ui_1 = require("./ui");
6
+ const schemas_1 = require("../../shared/src/schemas");
7
+ const utils_1 = require("../../shared/src/utils");
8
+ const chatPrompt_1 = require("./chatPrompt");
9
+ const render_1 = require("./render");
10
+ const menu_1 = require("./menu");
11
+ const input_1 = require("./input");
12
+ const colors_1 = require("./colors");
13
+ function isMenuMessage(msg) {
14
+ return msg.type === 'menu';
15
+ }
16
+ socket_1.socket.on('open', () => {
17
+ main().catch(err => {
18
+ console.error(err);
19
+ process.exit(1);
20
+ });
21
+ });
22
+ async function main() {
23
+ (0, render_1.clearScreen)();
24
+ setupMessageHandler();
25
+ const nickname = await getNickname();
26
+ let menu = await setNicknameAndGetMenu(nickname);
27
+ while (true) {
28
+ const action = await (0, menu_1.showMenu)(menu.payload.users, menu.payload.channels);
29
+ if (action.type === 'exit')
30
+ exitApp();
31
+ (0, socket_1.sendJson)(socket_1.socket, { type: 'join', payload: { channel: action.channel } });
32
+ (0, render_1.clearScreen)();
33
+ (0, ui_1.endInteractivePrompt)();
34
+ const reason = await chatLoop(nickname);
35
+ if (reason === 'exit')
36
+ exitApp();
37
+ (0, socket_1.sendJson)(socket_1.socket, { type: 'menu_request' });
38
+ menu = await (0, socket_1.waitForServerMessage)(socket_1.socket, isMenuMessage);
39
+ }
40
+ }
41
+ let onNickChanged = null;
42
+ let selfNick = '';
43
+ function setupMessageHandler() {
44
+ (0, socket_1.onServerMessage)(socket_1.socket, msg => {
45
+ switch (msg.type) {
46
+ case 'message':
47
+ (0, ui_1.printLine)(`[${(0, colors_1.colorize)(msg.payload.from)}] ${msg.payload.text}`);
48
+ break;
49
+ case 'channel_history':
50
+ (0, render_1.clearScreen)();
51
+ (0, colors_1.resetColors)();
52
+ (0, colors_1.assignColor)(selfNick);
53
+ (0, ui_1.printLines)(msg.payload.messages.map(m => `[${(0, colors_1.colorize)(m.from)}] ${m.message}`));
54
+ break;
55
+ case 'nick_changed':
56
+ onNickChanged?.(msg.payload.oldNick, msg.payload.newNick);
57
+ break;
58
+ case 'system':
59
+ (0, ui_1.printLine)(render_1.c.cyan(`* ${msg.payload.message} *`));
60
+ break;
61
+ case 'error':
62
+ (0, ui_1.printLine)(`Error: ${msg.payload.message}`);
63
+ break;
64
+ }
65
+ });
66
+ }
67
+ function exitApp() {
68
+ socket_1.socket.close();
69
+ process.exit(0);
70
+ }
71
+ async function getNickname() {
72
+ return (0, input_1.prompt)('Enter your nickname:', {
73
+ validate: async (value) => {
74
+ const nickname = value.trim();
75
+ const format = schemas_1.NicknameSchema.safeParse(nickname);
76
+ if (!format.success)
77
+ return (0, utils_1.zodErrorMessage)(format.error);
78
+ try {
79
+ const available = await checkNicknameAvailable(nickname);
80
+ return available ? true : 'Nickname already taken';
81
+ }
82
+ catch {
83
+ return 'Could not check nickname availability. Try again.';
84
+ }
85
+ },
86
+ });
87
+ }
88
+ async function checkNicknameAvailable(nickname) {
89
+ (0, socket_1.sendJson)(socket_1.socket, { type: 'check_nick', payload: { nickname } });
90
+ const reply = await (0, socket_1.waitForServerMessage)(socket_1.socket, m => m.type === 'nick_check' && m.payload.nickname === nickname, { timeoutMs: 5000 });
91
+ if (reply.type !== 'nick_check')
92
+ throw new Error('Unexpected server reply');
93
+ return reply.payload.available;
94
+ }
95
+ async function setNicknameAndGetMenu(nickname) {
96
+ (0, socket_1.sendJson)(socket_1.socket, { type: 'set_nick', payload: { nickname } });
97
+ return (0, socket_1.waitForServerMessage)(socket_1.socket, isMenuMessage, { timeoutMs: 5000 });
98
+ }
99
+ async function chatLoop(nickname) {
100
+ let currentNick = nickname;
101
+ selfNick = nickname;
102
+ const chat = (0, chatPrompt_1.createChatPrompt)();
103
+ (0, ui_1.setRealtimePrinter)(chat.printLine);
104
+ onNickChanged = (oldNick, newNick) => {
105
+ if (oldNick === currentNick) {
106
+ currentNick = newNick;
107
+ selfNick = newNick;
108
+ chat.updatePrompt(`[${(0, colors_1.colorize)(currentNick)}]> `);
109
+ }
110
+ else {
111
+ (0, ui_1.printLine)(render_1.c.cyan(`* ${oldNick} is now ${newNick} *`));
112
+ }
113
+ };
114
+ try {
115
+ while (true) {
116
+ const text = await chat.readLine(`[${(0, colors_1.colorize)(currentNick)}]> `);
117
+ if (!text)
118
+ continue;
119
+ if (text.startsWith('/')) {
120
+ const [cmd, ...args] = text.slice(1).split(' ');
121
+ switch (cmd) {
122
+ case 'home':
123
+ (0, socket_1.sendJson)(socket_1.socket, { type: 'leave_channel' });
124
+ return 'home';
125
+ case 'join':
126
+ (0, socket_1.sendJson)(socket_1.socket, {
127
+ type: 'join',
128
+ payload: { channel: args[0] },
129
+ });
130
+ continue;
131
+ case 'nick':
132
+ (0, socket_1.sendJson)(socket_1.socket, {
133
+ type: 'set_nick',
134
+ payload: { nickname: args[0] },
135
+ });
136
+ continue;
137
+ case 'exit':
138
+ return 'exit';
139
+ }
140
+ }
141
+ (0, socket_1.sendJson)(socket_1.socket, { type: 'message', payload: { text } });
142
+ }
143
+ }
144
+ finally {
145
+ onNickChanged = null;
146
+ (0, ui_1.setRealtimePrinter)(null);
147
+ chat.close();
148
+ }
149
+ }
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.prompt = prompt;
4
+ const ui_1 = require("./ui");
5
+ const prompts_1 = require("@inquirer/prompts");
6
+ const render_1 = require("./render");
7
+ async function prompt(text, options) {
8
+ (0, ui_1.beginInteractivePrompt)();
9
+ try {
10
+ return (await (0, prompts_1.input)({
11
+ message: text,
12
+ validate: options?.validate,
13
+ theme: render_1.promptTheme,
14
+ })).trim();
15
+ }
16
+ finally {
17
+ (0, ui_1.endInteractivePrompt)();
18
+ }
19
+ }
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.showMenu = showMenu;
4
+ const ui_1 = require("./ui");
5
+ const render_1 = require("./render");
6
+ const prompts_1 = require("@inquirer/prompts");
7
+ const BOX_W = 45;
8
+ const hr = render_1.c.gray('─'.repeat(BOX_W));
9
+ const hrDouble = render_1.c.gray('═'.repeat(BOX_W));
10
+ function header(title) {
11
+ console.log(hrDouble);
12
+ console.log(render_1.c.bold(render_1.c.white((0, render_1.centerText)(title, BOX_W))));
13
+ console.log(hrDouble);
14
+ }
15
+ async function showMenu(users, channels) {
16
+ (0, ui_1.setRealtimePrinter)(null);
17
+ while (true) {
18
+ (0, render_1.clearScreen)();
19
+ console.log(render_1.asciiArt);
20
+ console.log();
21
+ await (0, render_1.sleep)(400);
22
+ header('M E N U');
23
+ await (0, render_1.sleep)(100);
24
+ console.log();
25
+ printUsers(users);
26
+ (0, ui_1.beginInteractivePrompt)();
27
+ const selection = await (0, prompts_1.select)({
28
+ message: '',
29
+ choices: buildChoices(channels),
30
+ theme: render_1.promptTheme,
31
+ }).finally(() => (0, ui_1.endInteractivePrompt)());
32
+ if (selection.type !== 'commands')
33
+ return selection;
34
+ await showCommandsScreen();
35
+ }
36
+ }
37
+ function buildChoices(channels) {
38
+ return [
39
+ new prompts_1.Separator(render_1.c.gray('\n ┌── Channels ──────────────────┐')),
40
+ ...channels.map(channel => ({
41
+ name: render_1.c.cyan(` #${channel}`),
42
+ value: { type: 'join', channel },
43
+ })),
44
+ new prompts_1.Separator(render_1.c.gray(' └──────────────────────────────┘')),
45
+ new prompts_1.Separator(''),
46
+ { name: render_1.c.yellow(' ? Commands'), value: { type: 'commands' } },
47
+ { name: render_1.c.magenta(' ✕ Exit'), value: { type: 'exit' } },
48
+ ];
49
+ }
50
+ async function showCommandsScreen() {
51
+ (0, render_1.clearScreen)();
52
+ console.log();
53
+ header('C O M M A N D S');
54
+ console.log();
55
+ console.log(render_1.c.gray(' Available while in a channel:'));
56
+ console.log();
57
+ printCmd('/home', 'return to menu');
58
+ printCmd('/join <channel>', 'join/create a channel');
59
+ printCmd('/nick <nickname>', 'change your nickname');
60
+ printCmd('/exit', 'exit program');
61
+ console.log();
62
+ console.log(hr);
63
+ console.log();
64
+ (0, ui_1.beginInteractivePrompt)();
65
+ await (0, prompts_1.select)({
66
+ message: '',
67
+ choices: [{ name: render_1.c.gray('← Back to menu'), value: true }],
68
+ theme: render_1.promptTheme,
69
+ }).finally(() => (0, ui_1.endInteractivePrompt)());
70
+ }
71
+ function printCmd(cmd, desc) {
72
+ console.log(` ${render_1.c.cyan(cmd.padEnd(20))} ${render_1.c.dim(desc)}`);
73
+ }
74
+ function printUsers(users) {
75
+ console.log(render_1.c.bold(' Online'));
76
+ console.log(hr);
77
+ if (users.length === 0) {
78
+ console.log(render_1.c.dim(' (none)'));
79
+ }
80
+ else {
81
+ for (const user of users) {
82
+ console.log(` ${render_1.c.green('●')} ${user}`);
83
+ }
84
+ }
85
+ console.log();
86
+ }
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.asciiArt = exports.c = exports.promptTheme = void 0;
4
+ exports.clearScreen = clearScreen;
5
+ exports.sleep = sleep;
6
+ exports.centerText = centerText;
7
+ exports.promptTheme = {
8
+ prefix: '',
9
+ };
10
+ function clearScreen() {
11
+ process.stdout.write('\x1Bc');
12
+ }
13
+ function sleep(ms) {
14
+ return new Promise(resolve => setTimeout(resolve, ms));
15
+ }
16
+ // ── ANSI helpers ──
17
+ const ESC = '\x1b[';
18
+ const RESET = `${ESC}0m`;
19
+ exports.c = {
20
+ reset: RESET,
21
+ bold: (s) => `${ESC}1m${s}${RESET}`,
22
+ dim: (s) => `${ESC}2m${s}${RESET}`,
23
+ cyan: (s) => `${ESC}36m${s}${RESET}`,
24
+ green: (s) => `${ESC}32m${s}${RESET}`,
25
+ yellow: (s) => `${ESC}33m${s}${RESET}`,
26
+ magenta: (s) => `${ESC}35m${s}${RESET}`,
27
+ gray: (s) => `${ESC}90m${s}${RESET}`,
28
+ white: (s) => `${ESC}97m${s}${RESET}`,
29
+ };
30
+ function centerText(text, width) {
31
+ const pad = Math.max(0, Math.floor((width - text.length) / 2));
32
+ return ' '.repeat(pad) + text;
33
+ }
34
+ // ── ASCII art ──
35
+ const artColor = `${ESC}9${Math.floor(Math.random() * 8)}m`;
36
+ const dim = `${ESC}2m`;
37
+ const normal = `${ESC}22m`;
38
+ const cloudChars = ['·', '.', ':', '*', '~', '^', '`', '°', '•'];
39
+ function cloudSegment(length, density = 0.12) {
40
+ let out = '';
41
+ for (let i = 0; i < length; i++) {
42
+ out +=
43
+ Math.random() < density
44
+ ? cloudChars[Math.floor(Math.random() * cloudChars.length)]
45
+ : ' ';
46
+ }
47
+ return out;
48
+ }
49
+ const artLines = [
50
+ ' ______ ______ __ ____',
51
+ ' / ____/ / ____/ / / / _/',
52
+ ' / / / / / / / / ',
53
+ ' / /___ / /___ / /___ _/ / ',
54
+ ' \\____/ \\____/ /_____/ /___/ ',
55
+ ];
56
+ const leftPad = 10;
57
+ const rightPad = 10;
58
+ const maxLen = Math.max(...artLines.map(l => l.length));
59
+ const topBottom = [
60
+ cloudSegment(leftPad + maxLen + rightPad, 0.08),
61
+ cloudSegment(leftPad + maxLen + rightPad, 0.06),
62
+ ];
63
+ const framed = artLines.map(line => {
64
+ const left = cloudSegment(leftPad);
65
+ const right = cloudSegment(rightPad);
66
+ const padded = line.padEnd(maxLen, ' ');
67
+ return `${dim}${left}${normal}${padded}${dim}${right}${normal}`;
68
+ });
69
+ exports.asciiArt = `${artColor}${dim}${topBottom.join('\n')}${normal}\n` +
70
+ `${framed.join('\n')}\n` +
71
+ `${dim}${topBottom.slice().reverse().join('\n')}${RESET}`;
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.socket = void 0;
7
+ exports.sendJson = sendJson;
8
+ exports.onServerMessage = onServerMessage;
9
+ exports.waitForServerMessage = waitForServerMessage;
10
+ const schemas_1 = require("../../shared/src/schemas");
11
+ const ws_1 = require("ws");
12
+ const dotenv_1 = __importDefault(require("dotenv"));
13
+ dotenv_1.default.config();
14
+ exports.socket = new ws_1.WebSocket(process.env.SERVER_URL);
15
+ function sendJson(socket, msg) {
16
+ socket.send(JSON.stringify(msg));
17
+ }
18
+ function onServerMessage(socket, handler) {
19
+ socket.on('message', data => {
20
+ const msg = parseServerMessage(data.toString());
21
+ if (msg)
22
+ handler(msg);
23
+ });
24
+ }
25
+ function waitForServerMessage(socket, predicate, options) {
26
+ const timeoutMs = options?.timeoutMs ?? 10000;
27
+ return new Promise((resolve, reject) => {
28
+ const onMessage = (data) => {
29
+ const msg = parseServerMessage(data.toString());
30
+ if (!msg || !predicate(msg))
31
+ return;
32
+ cleanup();
33
+ resolve(msg);
34
+ };
35
+ const timer = setTimeout(() => {
36
+ cleanup();
37
+ reject(new Error('Timeout waiting for server message'));
38
+ }, timeoutMs);
39
+ const cleanup = () => {
40
+ clearTimeout(timer);
41
+ socket.off('message', onMessage);
42
+ };
43
+ socket.on('message', onMessage);
44
+ });
45
+ }
46
+ function parseServerMessage(text) {
47
+ try {
48
+ const result = schemas_1.ServerMessageSchema.safeParse(JSON.parse(text));
49
+ return result.success ? result.data : null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.beginInteractivePrompt = beginInteractivePrompt;
4
+ exports.endInteractivePrompt = endInteractivePrompt;
5
+ exports.setRealtimePrinter = setRealtimePrinter;
6
+ exports.printLine = printLine;
7
+ exports.printLines = printLines;
8
+ let interactivePromptActive = false;
9
+ let queuedLines = [];
10
+ let realtimePrinter = null;
11
+ function beginInteractivePrompt() {
12
+ interactivePromptActive = true;
13
+ }
14
+ function endInteractivePrompt() {
15
+ interactivePromptActive = false;
16
+ flushQueuedLines();
17
+ }
18
+ function setRealtimePrinter(printer) {
19
+ realtimePrinter = printer;
20
+ flushQueuedLines();
21
+ }
22
+ function printLine(line) {
23
+ if (realtimePrinter) {
24
+ realtimePrinter(line);
25
+ }
26
+ else if (interactivePromptActive) {
27
+ queuedLines.push(line);
28
+ }
29
+ else {
30
+ console.log(line);
31
+ }
32
+ }
33
+ function printLines(lines) {
34
+ lines.forEach(printLine);
35
+ }
36
+ function flushQueuedLines() {
37
+ if (queuedLines.length === 0 || interactivePromptActive)
38
+ return;
39
+ const lines = queuedLines;
40
+ queuedLines = [];
41
+ const print = realtimePrinter ?? console.log;
42
+ lines.forEach(print);
43
+ }
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleMessage = handleMessage;
4
+ const schemas_1 = require("../../shared/src/schemas");
5
+ const utils_1 = require("../../shared/src/utils");
6
+ const state_1 = require("./state");
7
+ function handleMessage(client, text) {
8
+ const msg = parseClientMessage(client, text);
9
+ if (!msg)
10
+ return;
11
+ switch (msg.type) {
12
+ case 'check_nick': {
13
+ const available = !state_1.state.listUsers().includes(msg.payload.nickname);
14
+ client.send({
15
+ type: 'nick_check',
16
+ payload: { nickname: msg.payload.nickname, available },
17
+ });
18
+ break;
19
+ }
20
+ case 'set_nick': {
21
+ const nick = msg.payload.nickname;
22
+ const oldNick = client.nickname;
23
+ if (state_1.state.listUsers().includes(nick) && oldNick !== nick) {
24
+ sendError(client, 'Nickname already taken');
25
+ return;
26
+ }
27
+ if (oldNick) {
28
+ state_1.state.changeNickname(client, nick);
29
+ const nickChangedMsg = {
30
+ type: 'nick_changed',
31
+ payload: { oldNick, newNick: nick },
32
+ };
33
+ client.send(nickChangedMsg);
34
+ if (client.currentChannel) {
35
+ state_1.state.broadcastToChannel(client.currentChannel, nickChangedMsg, nick);
36
+ }
37
+ }
38
+ else {
39
+ client.nickname = nick;
40
+ state_1.state.addClient(client);
41
+ state_1.state.broadcastSystem({
42
+ type: 'system',
43
+ payload: { message: `${nick} connected` },
44
+ }, nick);
45
+ }
46
+ sendMenu(client);
47
+ break;
48
+ }
49
+ case 'join': {
50
+ const oldChannel = client.currentChannel;
51
+ if (oldChannel) {
52
+ state_1.state.broadcastToChannel(oldChannel, {
53
+ type: 'system',
54
+ payload: { message: `${client.nickname} left #${oldChannel}` },
55
+ });
56
+ }
57
+ state_1.state.joinChannel(client, msg.payload.channel);
58
+ state_1.state.broadcastToChannel(msg.payload.channel, {
59
+ type: 'system',
60
+ payload: {
61
+ message: `${client.nickname} joined #${msg.payload.channel}`,
62
+ },
63
+ });
64
+ break;
65
+ }
66
+ case 'leave_channel': {
67
+ if (!client.nickname) {
68
+ sendError(client, 'Set your nickname first');
69
+ return;
70
+ }
71
+ const oldChannel = client.currentChannel;
72
+ if (!oldChannel)
73
+ break;
74
+ state_1.state.leaveChannel(client);
75
+ state_1.state.broadcastToChannel(oldChannel, {
76
+ type: 'system',
77
+ payload: { message: `${client.nickname} left #${oldChannel}` },
78
+ });
79
+ break;
80
+ }
81
+ case 'message': {
82
+ if (!client.nickname) {
83
+ sendError(client, 'Set your nickname first');
84
+ return;
85
+ }
86
+ if (!client.currentChannel) {
87
+ sendError(client, 'Join a channel first');
88
+ return;
89
+ }
90
+ state_1.state
91
+ .getOrCreateChannel(client.currentChannel)
92
+ .addMessage({ from: client.nickname, message: msg.payload.text });
93
+ state_1.state.broadcastToChannel(client.currentChannel, {
94
+ type: 'message',
95
+ payload: { from: client.nickname, text: msg.payload.text },
96
+ }, client.nickname);
97
+ break;
98
+ }
99
+ case 'menu_request':
100
+ sendMenu(client);
101
+ break;
102
+ }
103
+ }
104
+ function parseClientMessage(client, text) {
105
+ let parsed;
106
+ try {
107
+ parsed = JSON.parse(text);
108
+ }
109
+ catch {
110
+ sendError(client, 'Invalid JSON received');
111
+ return;
112
+ }
113
+ const result = schemas_1.ClientMessageSchema.safeParse(parsed);
114
+ if (!result.success) {
115
+ sendError(client, (0, utils_1.zodErrorMessage)(result.error));
116
+ return;
117
+ }
118
+ return result.data;
119
+ }
120
+ function sendError(client, message) {
121
+ client.send({ type: 'error', payload: { message } });
122
+ }
123
+ function sendMenu(client) {
124
+ if (!client.nickname) {
125
+ sendError(client, 'Set your nickname first');
126
+ return;
127
+ }
128
+ client.send({
129
+ type: 'menu',
130
+ payload: {
131
+ self: client.nickname,
132
+ users: state_1.state.listUsers(client.nickname),
133
+ channels: state_1.state.listChannels(),
134
+ },
135
+ });
136
+ }
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const server_1 = require("./server");
4
+ const port = Number(process.env.PORT) || 8080;
5
+ (0, server_1.startServer)(port);
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.startServer = startServer;
4
+ const ws_1 = require("ws");
5
+ const commands_1 = require("./commands");
6
+ const state_1 = require("./state");
7
+ function startServer(port = 8080) {
8
+ const wss = new ws_1.WebSocketServer({ port });
9
+ wss.on('connection', (socket) => {
10
+ const client = new state_1.Client(socket);
11
+ socket.on('message', data => {
12
+ (0, commands_1.handleMessage)(client, data.toString());
13
+ });
14
+ socket.on('close', () => {
15
+ const nickname = client.nickname;
16
+ if (nickname) {
17
+ state_1.state.broadcastSystem({
18
+ type: 'system',
19
+ payload: { message: `${nickname} disconnected` },
20
+ }, nickname);
21
+ }
22
+ state_1.state.removeClient(client);
23
+ });
24
+ });
25
+ console.log(`WebSocket server running on ws://localhost:${port}`);
26
+ }
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.state = exports.State = exports.Channel = exports.Client = void 0;
4
+ class Client {
5
+ constructor(socket) {
6
+ this.nickname = null;
7
+ this.currentChannel = null;
8
+ this.socket = socket;
9
+ }
10
+ send(data) {
11
+ this.socket.send(JSON.stringify(data));
12
+ }
13
+ }
14
+ exports.Client = Client;
15
+ class Channel {
16
+ constructor(name) {
17
+ this.name = name;
18
+ this.clients = new Set();
19
+ this.history = [];
20
+ this.MAX_HISTORY = 150;
21
+ }
22
+ hasClient(nickname) {
23
+ return this.clients.has(nickname);
24
+ }
25
+ addClient(nickname) {
26
+ this.clients.add(nickname);
27
+ }
28
+ removeClient(nickname) {
29
+ this.clients.delete(nickname);
30
+ }
31
+ listClients() {
32
+ return [...this.clients];
33
+ }
34
+ addMessage(message) {
35
+ this.history.push(message);
36
+ if (this.history.length > this.MAX_HISTORY) {
37
+ this.history.shift();
38
+ }
39
+ }
40
+ getHistory() {
41
+ return [...this.history];
42
+ }
43
+ clearHistory() {
44
+ this.history.length = 0;
45
+ }
46
+ }
47
+ exports.Channel = Channel;
48
+ class State {
49
+ constructor() {
50
+ this.clients = new Map();
51
+ this.channels = new Map();
52
+ }
53
+ addClient(client) {
54
+ if (!client.nickname)
55
+ return;
56
+ this.clients.set(client.nickname, client);
57
+ }
58
+ removeClient(client) {
59
+ if (!client.nickname)
60
+ return;
61
+ if (client.currentChannel) {
62
+ const channel = this.channels.get(client.currentChannel);
63
+ if (channel) {
64
+ channel.removeClient(client.nickname);
65
+ this.cleanupEmptyChannel(channel);
66
+ }
67
+ }
68
+ this.clients.delete(client.nickname);
69
+ if (this.clients.size === 0) {
70
+ this.channels.get('general')?.clearHistory();
71
+ }
72
+ this.cleanupAllEmptyChannels();
73
+ }
74
+ getClient(nickname) {
75
+ return this.clients.get(nickname);
76
+ }
77
+ listUsers(except) {
78
+ return [...this.clients.keys()].filter(n => n !== except);
79
+ }
80
+ getOrCreateChannel(name) {
81
+ let channel = this.channels.get(name);
82
+ if (!channel) {
83
+ channel = new Channel(name);
84
+ this.channels.set(name, channel);
85
+ }
86
+ return channel;
87
+ }
88
+ listChannels() {
89
+ return [...this.channels.keys()];
90
+ }
91
+ joinChannel(client, channelName) {
92
+ if (!client.nickname)
93
+ return;
94
+ if (client.currentChannel) {
95
+ const oldChannel = this.channels.get(client.currentChannel);
96
+ oldChannel?.removeClient(client.nickname);
97
+ }
98
+ const channel = this.getOrCreateChannel(channelName);
99
+ channel.addClient(client.nickname);
100
+ client.send({
101
+ type: 'channel_history',
102
+ payload: { channel: channel.name, messages: channel.getHistory() },
103
+ });
104
+ client.currentChannel = channelName;
105
+ }
106
+ leaveChannel(client) {
107
+ if (!client.nickname || !client.currentChannel)
108
+ return;
109
+ const channel = this.channels.get(client.currentChannel);
110
+ if (channel) {
111
+ channel.removeClient(client.nickname);
112
+ this.cleanupEmptyChannel(channel);
113
+ }
114
+ client.currentChannel = null;
115
+ }
116
+ broadcastToChannel(channelName, data, exceptNick) {
117
+ const channel = this.channels.get(channelName);
118
+ if (!channel)
119
+ return;
120
+ for (const nick of channel.listClients()) {
121
+ if (nick !== exceptNick) {
122
+ this.clients.get(nick)?.send(data);
123
+ }
124
+ }
125
+ }
126
+ changeNickname(client, newNick) {
127
+ const oldNick = client.nickname;
128
+ if (!oldNick)
129
+ return;
130
+ this.clients.delete(oldNick);
131
+ client.nickname = newNick;
132
+ this.clients.set(newNick, client);
133
+ if (client.currentChannel) {
134
+ const channel = this.channels.get(client.currentChannel);
135
+ if (channel) {
136
+ channel.removeClient(oldNick);
137
+ channel.addClient(newNick);
138
+ }
139
+ }
140
+ }
141
+ broadcastSystem(data, exceptNick) {
142
+ for (const [nick, client] of this.clients.entries()) {
143
+ if (nick !== exceptNick) {
144
+ client.send(data);
145
+ }
146
+ }
147
+ }
148
+ cleanupEmptyChannel(channel) {
149
+ if (channel.listClients().length === 0 &&
150
+ channel.getHistory().length > 1 &&
151
+ channel.name !== 'general') {
152
+ this.channels.delete(channel.name);
153
+ }
154
+ }
155
+ cleanupAllEmptyChannels() {
156
+ for (const [name, channel] of this.channels) {
157
+ if (name !== 'general' && channel.listClients().length === 0) {
158
+ this.channels.delete(name);
159
+ }
160
+ }
161
+ }
162
+ }
163
+ exports.State = State;
164
+ exports.state = new State();
165
+ exports.state.getOrCreateChannel('general');
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ServerMessageSchema = exports.ClientMessageSchema = exports.NicknameSchema = exports.ChannelNameSchema = void 0;
4
+ const zod_1 = require("zod");
5
+ exports.ChannelNameSchema = zod_1.z
6
+ .string()
7
+ .min(1)
8
+ .max(30)
9
+ .regex(/^[a-z0-9_-]+$/, 'Channel name must be lowercase and contain only letters, numbers, _ or -');
10
+ exports.NicknameSchema = zod_1.z
11
+ .string()
12
+ .min(1)
13
+ .max(20)
14
+ .regex(/^[a-zA-Z0-9_-]+$/, 'Nickname must contain only letters, numbers, "_" or "-", and no spaces');
15
+ const SetNickMessageSchema = zod_1.z.object({
16
+ type: zod_1.z.literal('set_nick'),
17
+ payload: zod_1.z.object({
18
+ nickname: exports.NicknameSchema,
19
+ }),
20
+ });
21
+ const CheckNickMessageSchema = zod_1.z.object({
22
+ type: zod_1.z.literal('check_nick'),
23
+ payload: zod_1.z.object({
24
+ nickname: exports.NicknameSchema,
25
+ }),
26
+ });
27
+ const JoinMessageSchema = zod_1.z.object({
28
+ type: zod_1.z.literal('join'),
29
+ payload: zod_1.z.object({
30
+ channel: exports.ChannelNameSchema,
31
+ }),
32
+ });
33
+ const ChatMessageSchema = zod_1.z.object({
34
+ type: zod_1.z.literal('message'),
35
+ payload: zod_1.z.object({
36
+ text: zod_1.z.string().min(1).max(500),
37
+ }),
38
+ });
39
+ const MenuRequestSchema = zod_1.z.object({
40
+ type: zod_1.z.literal('menu_request'),
41
+ });
42
+ const LeaveChannelSchema = zod_1.z.object({
43
+ type: zod_1.z.literal('leave_channel'),
44
+ });
45
+ exports.ClientMessageSchema = zod_1.z.union([
46
+ SetNickMessageSchema,
47
+ CheckNickMessageSchema,
48
+ JoinMessageSchema,
49
+ ChatMessageSchema,
50
+ MenuRequestSchema,
51
+ LeaveChannelSchema,
52
+ ]);
53
+ const ServerChatMessageSchema = zod_1.z.object({
54
+ type: zod_1.z.literal('message'),
55
+ payload: zod_1.z.object({
56
+ from: zod_1.z.string(),
57
+ text: zod_1.z.string().min(1).max(500),
58
+ }),
59
+ });
60
+ const ServerErrorMessageSchema = zod_1.z.object({
61
+ type: zod_1.z.literal('error'),
62
+ payload: zod_1.z.object({
63
+ message: zod_1.z.string(),
64
+ }),
65
+ });
66
+ const ServerNickCheckSchema = zod_1.z.object({
67
+ type: zod_1.z.literal('nick_check'),
68
+ payload: zod_1.z.object({
69
+ nickname: zod_1.z.string(),
70
+ available: zod_1.z.boolean(),
71
+ }),
72
+ });
73
+ const ServerWelcomeMessageSchema = zod_1.z.object({
74
+ type: zod_1.z.literal('welcome'),
75
+ payload: zod_1.z.object({
76
+ nickname: zod_1.z.string(),
77
+ users: zod_1.z.array(zod_1.z.string()),
78
+ channels: zod_1.z.array(zod_1.z.string()),
79
+ }),
80
+ });
81
+ const ServerUserJoinedSchema = zod_1.z.object({
82
+ type: zod_1.z.literal('user_joined'),
83
+ payload: zod_1.z.object({
84
+ nickname: zod_1.z.string(),
85
+ channel: zod_1.z.string(),
86
+ }),
87
+ });
88
+ const ServerUserLeftSchema = zod_1.z.object({
89
+ type: zod_1.z.literal('user_left'),
90
+ payload: zod_1.z.object({
91
+ nickname: zod_1.z.string(),
92
+ }),
93
+ });
94
+ const ServerNickChangedSchema = zod_1.z.object({
95
+ type: zod_1.z.literal('nick_changed'),
96
+ payload: zod_1.z.object({
97
+ oldNick: zod_1.z.string(),
98
+ newNick: zod_1.z.string(),
99
+ }),
100
+ });
101
+ const ServerMenuSchema = zod_1.z.object({
102
+ type: zod_1.z.literal('menu'),
103
+ payload: zod_1.z.object({
104
+ self: zod_1.z.string(),
105
+ users: zod_1.z.array(zod_1.z.string()),
106
+ channels: zod_1.z.array(zod_1.z.string()),
107
+ }),
108
+ });
109
+ const ServerChannelHistorySchema = zod_1.z.object({
110
+ type: zod_1.z.literal('channel_history'),
111
+ payload: zod_1.z.object({
112
+ channel: zod_1.z.string(),
113
+ messages: zod_1.z.array(zod_1.z.object({
114
+ from: zod_1.z.string(),
115
+ message: zod_1.z.string(),
116
+ })),
117
+ }),
118
+ });
119
+ const ServerSystemMessageSchema = zod_1.z.object({
120
+ type: zod_1.z.literal('system'),
121
+ payload: zod_1.z.object({
122
+ message: zod_1.z.string(),
123
+ }),
124
+ });
125
+ exports.ServerMessageSchema = zod_1.z.union([
126
+ ServerChatMessageSchema,
127
+ ServerErrorMessageSchema,
128
+ ServerWelcomeMessageSchema,
129
+ ServerUserJoinedSchema,
130
+ ServerUserLeftSchema,
131
+ ServerNickChangedSchema,
132
+ ServerNickCheckSchema,
133
+ ServerMenuSchema,
134
+ ServerChannelHistorySchema,
135
+ ServerSystemMessageSchema,
136
+ ]);
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.zodErrorMessage = zodErrorMessage;
4
+ function zodErrorMessage(err) {
5
+ if (!err.issues.length) {
6
+ return 'Invalid message format';
7
+ }
8
+ return err.issues.map(i => i.message).join('\n');
9
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@bruggmann._/ccli",
3
+ "private": false,
4
+ "version": "1.0.0",
5
+ "description": "Chat Command Line Interface using WebSockets (TypeScript)",
6
+ "bin": {
7
+ "ccli": "dist/packages/client/src/index.js"
8
+ },
9
+ "main": "dist/packages/client/src/index.js",
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "prepublishOnly": "npm run build",
15
+ "build": "tsc --project tsconfig.build.json && tsc-alias --project tsconfig.build.json",
16
+ "start": "node dist/packages/server/src/index.js",
17
+ "server": "nodemon --watch packages/server --exec \"ts-node -r tsconfig-paths/register --project tsconfig.json\" packages/server/src/index.ts",
18
+ "client": "ts-node -r tsconfig-paths/register --project packages/client/tsconfig.json packages/client/src/index.ts"
19
+ },
20
+ "keywords": [
21
+ "cli",
22
+ "chat",
23
+ "websocket",
24
+ "typescript"
25
+ ],
26
+ "author": "",
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "@inquirer/prompts": "^8.2.0",
30
+ "dotenv": "^17.2.4",
31
+ "undici-types": "^7.19.1",
32
+ "ws": "^8.19.0",
33
+ "zod": "^4.3.6"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^25.0.10",
37
+ "@types/ws": "^8.18.1",
38
+ "nodemon": "^3.1.11",
39
+ "ts-node": "^10.9.2",
40
+ "tsc-alias": "^1.8.16",
41
+ "tsconfig-paths": "^4.2.0",
42
+ "typescript": "^5.9.3"
43
+ }
44
+ }