chat-ma 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 +48 -3
- package/bin/chat.js +40 -7
- package/client/lib/localConfig.js +34 -2
- package/client/lib/wsClient.js +5 -1
- package/ecosystem.config.cjs +18 -0
- package/package.json +6 -2
- package/server/config.js +8 -0
- package/server/server/data/users.db +0 -0
- package/server/server.js +27 -5
- package/server/ws.js +11 -1
package/README.md
CHANGED
|
@@ -17,13 +17,58 @@ Cinematic Matrix-style one-time terminal messenger distributed as an npm CLI pac
|
|
|
17
17
|
npm install
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
##
|
|
20
|
+
## Server URL used by CLI
|
|
21
|
+
|
|
22
|
+
Default CLI server URL is:
|
|
23
|
+
|
|
24
|
+
`https://api.secondhandcell.com/chat-ma`
|
|
25
|
+
|
|
26
|
+
Override any time with:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
export CHAT_MA_SERVER=https://api.secondhandcell.com/chat-ma
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Run server directly
|
|
21
33
|
|
|
22
34
|
```bash
|
|
23
35
|
npm run serve
|
|
24
36
|
```
|
|
25
37
|
|
|
26
|
-
Server
|
|
38
|
+
Server defaults:
|
|
39
|
+
|
|
40
|
+
- `PORT=3000`
|
|
41
|
+
- `CHAT_MA_BASE_PATH=/chat-ma`
|
|
42
|
+
|
|
43
|
+
So API endpoints are served under `/chat-ma/*`.
|
|
44
|
+
|
|
45
|
+
## Run server with PM2 (always on)
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm run pm2:start
|
|
49
|
+
npm run pm2:logs
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Restart / stop:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npm run pm2:restart
|
|
56
|
+
npm run pm2:stop
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Persist across host reboot:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx pm2 save
|
|
63
|
+
npx pm2 startup
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Reverse proxy example
|
|
67
|
+
|
|
68
|
+
Proxy `https://api.secondhandcell.com/chat-ma` (including websocket upgrades) to your Node process on `127.0.0.1:3000`.
|
|
69
|
+
|
|
70
|
+
- HTTP: `/chat-ma/register`, `/chat-ma/login`, `/chat-ma/send`, `/chat-ma/verify-password`
|
|
71
|
+
- WS: `/chat-ma/ws`
|
|
27
72
|
|
|
28
73
|
## Use CLI
|
|
29
74
|
|
|
@@ -44,7 +89,7 @@ Example:
|
|
|
44
89
|
|
|
45
90
|
```json
|
|
46
91
|
{
|
|
47
|
-
"serverUrl": "
|
|
92
|
+
"serverUrl": "https://api.secondhandcell.com/chat-ma",
|
|
48
93
|
"token": "...",
|
|
49
94
|
"username": "alice"
|
|
50
95
|
}
|
package/bin/chat.js
CHANGED
|
@@ -3,7 +3,12 @@ import blessed from 'blessed';
|
|
|
3
3
|
import ora from 'ora';
|
|
4
4
|
import cliProgress from 'cli-progress';
|
|
5
5
|
import { askCredentials, askSendPayload } from '../client/lib/prompts.js';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
getServerCandidates,
|
|
8
|
+
loadLocalConfig,
|
|
9
|
+
requireAuthConfig,
|
|
10
|
+
saveLocalConfig
|
|
11
|
+
} from '../client/lib/localConfig.js';
|
|
7
12
|
import { printBanner } from '../client/lib/ui.js';
|
|
8
13
|
import {
|
|
9
14
|
showAuthorizedBox,
|
|
@@ -33,15 +38,41 @@ async function postJson(url, payload, token) {
|
|
|
33
38
|
return data;
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
function withPath(baseUrl, path) {
|
|
42
|
+
return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function postJsonWithFallback(cfg, path, payload, token) {
|
|
46
|
+
const urls = getServerCandidates(cfg);
|
|
47
|
+
let lastError;
|
|
48
|
+
|
|
49
|
+
for (const baseUrl of urls) {
|
|
50
|
+
try {
|
|
51
|
+
const data = await postJson(withPath(baseUrl, path), payload, token);
|
|
52
|
+
return { data, baseUrl };
|
|
53
|
+
} catch (err) {
|
|
54
|
+
lastError = err;
|
|
55
|
+
if (err?.message !== 'fetch failed') {
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw new Error(
|
|
62
|
+
`${lastError?.message || 'Request failed'} (server unreachable: tried ${urls.join(', ')}). ` +
|
|
63
|
+
'Start server with: npm run serve, or set CHAT_MA_SERVER=http://host:port/chat-ma'
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
36
67
|
async function register() {
|
|
37
68
|
printBanner();
|
|
38
69
|
const cfg = loadLocalConfig();
|
|
39
70
|
const { username, password } = await askCredentials('Register');
|
|
40
71
|
const spinner = ora('Provisioning identity...').start();
|
|
41
72
|
try {
|
|
42
|
-
const data = await
|
|
73
|
+
const { data, baseUrl } = await postJsonWithFallback(cfg, '/register', { username, password });
|
|
43
74
|
spinner.succeed('Identity created');
|
|
44
|
-
saveLocalConfig({ token: data.token, username: data.username, serverUrl:
|
|
75
|
+
saveLocalConfig({ token: data.token, username: data.username, serverUrl: baseUrl });
|
|
45
76
|
showAuthorizedBox();
|
|
46
77
|
} catch (err) {
|
|
47
78
|
spinner.fail(err.message);
|
|
@@ -55,9 +86,9 @@ async function login() {
|
|
|
55
86
|
const { username, password } = await askCredentials('Login');
|
|
56
87
|
const spinner = ora('Authenticating...').start();
|
|
57
88
|
try {
|
|
58
|
-
const data = await
|
|
89
|
+
const { data, baseUrl } = await postJsonWithFallback(cfg, '/login', { username, password });
|
|
59
90
|
spinner.succeed('Session established');
|
|
60
|
-
saveLocalConfig({ token: data.token, username: data.username, serverUrl:
|
|
91
|
+
saveLocalConfig({ token: data.token, username: data.username, serverUrl: baseUrl });
|
|
61
92
|
showAuthorizedBox();
|
|
62
93
|
} catch (err) {
|
|
63
94
|
spinner.fail(err.message);
|
|
@@ -80,7 +111,8 @@ async function send() {
|
|
|
80
111
|
bar.update(85);
|
|
81
112
|
|
|
82
113
|
try {
|
|
83
|
-
await
|
|
114
|
+
const { baseUrl } = await postJsonWithFallback(cfg, '/send', { to, body }, cfg.token);
|
|
115
|
+
saveLocalConfig({ serverUrl: baseUrl });
|
|
84
116
|
bar.update(100);
|
|
85
117
|
bar.stop();
|
|
86
118
|
showMessageSentBox();
|
|
@@ -237,7 +269,8 @@ async function openInbox() {
|
|
|
237
269
|
screen.render();
|
|
238
270
|
const password = await askPasswordInScreen(screen);
|
|
239
271
|
try {
|
|
240
|
-
await
|
|
272
|
+
const { baseUrl } = await postJsonWithFallback(cfg, '/verify-password', { password }, cfg.token);
|
|
273
|
+
saveLocalConfig({ serverUrl: baseUrl });
|
|
241
274
|
ws.send(JSON.stringify({ type: 'VIEW_REQUEST', id: currentIncoming.id }));
|
|
242
275
|
} catch {
|
|
243
276
|
modal.setItems(['', ' ACCESS DENIED ', '', ' [ OK ]']);
|
|
@@ -5,19 +5,51 @@ import path from 'path';
|
|
|
5
5
|
const cfgDir = path.join(os.homedir(), '.chat-ma');
|
|
6
6
|
const cfgPath = path.join(cfgDir, 'config.json');
|
|
7
7
|
|
|
8
|
+
function normalizeServerUrl(url) {
|
|
9
|
+
if (!url) return url;
|
|
10
|
+
return url.replace(/\/+$/, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
export function loadLocalConfig() {
|
|
9
14
|
try {
|
|
10
15
|
return JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
11
16
|
} catch {
|
|
17
|
+
const envUrl = process.env.CHAT_MA_SERVER;
|
|
18
|
+
const defaultHosted = 'https://api.secondhandcell.com/chat-ma';
|
|
12
19
|
return {
|
|
13
|
-
serverUrl:
|
|
20
|
+
serverUrl: normalizeServerUrl(envUrl || defaultHosted)
|
|
14
21
|
};
|
|
15
22
|
}
|
|
16
23
|
}
|
|
17
24
|
|
|
25
|
+
export function getServerCandidates(cfg) {
|
|
26
|
+
const preferred = normalizeServerUrl(
|
|
27
|
+
cfg.serverUrl || process.env.CHAT_MA_SERVER || 'https://api.secondhandcell.com/chat-ma'
|
|
28
|
+
);
|
|
29
|
+
const candidates = [preferred];
|
|
30
|
+
|
|
31
|
+
if (/localhost:3000\/?$/.test(preferred)) {
|
|
32
|
+
candidates.push(preferred.replace('localhost:3000', 'localhost:3001'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (/127\.0\.0\.1:3000\/?$/.test(preferred)) {
|
|
36
|
+
candidates.push(preferred.replace('127.0.0.1:3000', '127.0.0.1:3001'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (preferred.endsWith('/chat-ma')) {
|
|
40
|
+
candidates.push(preferred.replace(/\/chat-ma$/, ''));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return [...new Set(candidates.map(normalizeServerUrl).filter(Boolean))];
|
|
44
|
+
}
|
|
45
|
+
|
|
18
46
|
export function saveLocalConfig(partial) {
|
|
19
47
|
const current = loadLocalConfig();
|
|
20
|
-
const merged = {
|
|
48
|
+
const merged = {
|
|
49
|
+
...current,
|
|
50
|
+
...partial,
|
|
51
|
+
...(partial.serverUrl ? { serverUrl: normalizeServerUrl(partial.serverUrl) } : {})
|
|
52
|
+
};
|
|
21
53
|
fs.mkdirSync(cfgDir, { recursive: true });
|
|
22
54
|
fs.writeFileSync(cfgPath, JSON.stringify(merged, null, 2));
|
|
23
55
|
return merged;
|
package/client/lib/wsClient.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import WebSocket from 'ws';
|
|
2
2
|
|
|
3
|
+
function withPath(baseUrl, path) {
|
|
4
|
+
return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
3
7
|
export function connectWs(serverHttpUrl, token, handlers) {
|
|
4
|
-
const wsUrl = serverHttpUrl.replace(/^http/, 'ws')
|
|
8
|
+
const wsUrl = withPath(serverHttpUrl.replace(/^http/, 'ws'), '/ws');
|
|
5
9
|
const ws = new WebSocket(wsUrl);
|
|
6
10
|
|
|
7
11
|
ws.on('open', () => {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
apps: [
|
|
3
|
+
{
|
|
4
|
+
name: 'chat-ma',
|
|
5
|
+
script: 'server/server.js',
|
|
6
|
+
cwd: __dirname,
|
|
7
|
+
instances: 1,
|
|
8
|
+
autorestart: true,
|
|
9
|
+
watch: false,
|
|
10
|
+
max_memory_restart: '300M',
|
|
11
|
+
env: {
|
|
12
|
+
NODE_ENV: 'production',
|
|
13
|
+
PORT: 3000,
|
|
14
|
+
CHAT_MA_BASE_PATH: '/chat-ma'
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
};
|
package/package.json
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chat-ma",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Cinematic Matrix-style ephemeral terminal messenger",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"chat-ma": "./bin/chat.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"serve": "node server/server.js"
|
|
10
|
+
"serve": "node server/server.js",
|
|
11
|
+
"pm2:start": "npx pm2 start ecosystem.config.cjs --update-env",
|
|
12
|
+
"pm2:restart": "npx pm2 restart chat-ma --update-env",
|
|
13
|
+
"pm2:stop": "npx pm2 stop chat-ma",
|
|
14
|
+
"pm2:logs": "npx pm2 logs chat-ma"
|
|
11
15
|
},
|
|
12
16
|
"dependencies": {
|
|
13
17
|
"bcrypt": "^5.1.1",
|
package/server/config.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import os from 'os';
|
|
3
3
|
|
|
4
|
+
function normalizeBasePath(input) {
|
|
5
|
+
if (!input || input === '/') return '';
|
|
6
|
+
const trimmed = input.trim();
|
|
7
|
+
const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
8
|
+
return withLeading.replace(/\/+$/, '');
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
export const config = {
|
|
5
12
|
port: process.env.PORT ? Number(process.env.PORT) : 3000,
|
|
6
13
|
jwtSecret: process.env.JWT_SECRET || 'chat-ma-dev-secret-change-me',
|
|
7
14
|
jwtExpiresIn: '7d',
|
|
8
15
|
messageTtlMs: 5 * 60 * 1000,
|
|
16
|
+
basePath: normalizeBasePath(process.env.CHAT_MA_BASE_PATH || '/chat-ma'),
|
|
9
17
|
dataDir: path.resolve(process.cwd(), 'server', 'data'),
|
|
10
18
|
userDbPath: path.resolve(process.cwd(), 'server', 'data', 'users.db'),
|
|
11
19
|
clientConfigPath: path.join(os.homedir(), '.chat-ma', 'config.json')
|
|
Binary file
|
package/server/server.js
CHANGED
|
@@ -34,8 +34,28 @@ function getBearer(req) {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function getPathname(reqUrl) {
|
|
38
|
+
const base = reqUrl.startsWith('http') ? undefined : 'http://localhost';
|
|
39
|
+
return new URL(reqUrl, base).pathname;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stripBasePath(pathname) {
|
|
43
|
+
if (!config.basePath) return pathname;
|
|
44
|
+
if (pathname === config.basePath) return '/';
|
|
45
|
+
if (pathname.startsWith(`${config.basePath}/`)) {
|
|
46
|
+
return pathname.slice(config.basePath.length);
|
|
47
|
+
}
|
|
48
|
+
return pathname;
|
|
49
|
+
}
|
|
50
|
+
|
|
37
51
|
const server = http.createServer(async (req, res) => {
|
|
38
|
-
|
|
52
|
+
const pathname = stripBasePath(getPathname(req.url));
|
|
53
|
+
|
|
54
|
+
if (req.method === 'GET' && pathname === '/health') {
|
|
55
|
+
return json(res, 200, { ok: true, basePath: config.basePath || '/' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (req.method === 'POST' && pathname === '/register') {
|
|
39
59
|
const limit = authLimiter(`register:${getIp(req)}`);
|
|
40
60
|
if (!limit.allowed) return json(res, 429, { error: 'Too many attempts' });
|
|
41
61
|
try {
|
|
@@ -50,7 +70,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
50
70
|
}
|
|
51
71
|
}
|
|
52
72
|
|
|
53
|
-
if (req.method === 'POST' &&
|
|
73
|
+
if (req.method === 'POST' && pathname === '/login') {
|
|
54
74
|
const limit = authLimiter(`login:${getIp(req)}`);
|
|
55
75
|
if (!limit.allowed) return json(res, 429, { error: 'Too many attempts' });
|
|
56
76
|
try {
|
|
@@ -65,7 +85,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
65
85
|
}
|
|
66
86
|
}
|
|
67
87
|
|
|
68
|
-
if (req.method === 'POST' &&
|
|
88
|
+
if (req.method === 'POST' && pathname === '/send') {
|
|
69
89
|
const auth = getBearer(req);
|
|
70
90
|
if (!auth) return json(res, 401, { error: 'Unauthorized' });
|
|
71
91
|
try {
|
|
@@ -83,7 +103,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
83
103
|
}
|
|
84
104
|
}
|
|
85
105
|
|
|
86
|
-
if (req.method === 'POST' &&
|
|
106
|
+
if (req.method === 'POST' && pathname === '/verify-password') {
|
|
87
107
|
const auth = getBearer(req);
|
|
88
108
|
if (!auth) return json(res, 401, { error: 'Unauthorized' });
|
|
89
109
|
|
|
@@ -102,5 +122,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
102
122
|
attachWsServer(server);
|
|
103
123
|
|
|
104
124
|
server.listen(config.port, () => {
|
|
105
|
-
process.stdout.write(
|
|
125
|
+
process.stdout.write(
|
|
126
|
+
`chat-ma server listening on ${config.port} (basePath: ${config.basePath || '/'})\n`
|
|
127
|
+
);
|
|
106
128
|
});
|
package/server/ws.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { WebSocketServer } from 'ws';
|
|
2
|
+
import { config } from './config.js';
|
|
2
3
|
import { verifyToken } from './auth.js';
|
|
3
4
|
import {
|
|
4
5
|
deleteMessage,
|
|
@@ -9,11 +10,20 @@ import {
|
|
|
9
10
|
|
|
10
11
|
const sessionsByUser = new Map();
|
|
11
12
|
|
|
13
|
+
function getPathname(reqUrl) {
|
|
14
|
+
const base = reqUrl.startsWith('http') ? undefined : 'http://localhost';
|
|
15
|
+
return new URL(reqUrl, base).pathname;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function wsPath() {
|
|
19
|
+
return `${config.basePath || ''}/ws`;
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
export function attachWsServer(httpServer) {
|
|
13
23
|
const wss = new WebSocketServer({ noServer: true });
|
|
14
24
|
|
|
15
25
|
httpServer.on('upgrade', (req, socket, head) => {
|
|
16
|
-
if (req.url !==
|
|
26
|
+
if (getPathname(req.url) !== wsPath()) {
|
|
17
27
|
socket.destroy();
|
|
18
28
|
return;
|
|
19
29
|
}
|