@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 +64 -0
- package/dist/packages/client/src/chatPrompt.js +46 -0
- package/dist/packages/client/src/colors.js +40 -0
- package/dist/packages/client/src/index.js +149 -0
- package/dist/packages/client/src/input.js +19 -0
- package/dist/packages/client/src/menu.js +86 -0
- package/dist/packages/client/src/render.js +71 -0
- package/dist/packages/client/src/socket.js +54 -0
- package/dist/packages/client/src/ui.js +43 -0
- package/dist/packages/server/src/commands.js +136 -0
- package/dist/packages/server/src/index.js +5 -0
- package/dist/packages/server/src/server.js +26 -0
- package/dist/packages/server/src/state.js +165 -0
- package/dist/packages/shared/src/schemas.js +136 -0
- package/dist/packages/shared/src/utils.js +9 -0
- package/package.json +44 -0
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,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
|
+
}
|