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 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
- ## Run server
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 default: `http://localhost:3000`
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": "http://localhost:3000",
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 { loadLocalConfig, requireAuthConfig, saveLocalConfig } from '../client/lib/localConfig.js';
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 postJson(`${cfg.serverUrl}/register`, { username, password });
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: cfg.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 postJson(`${cfg.serverUrl}/login`, { username, password });
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: cfg.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 postJson(`${cfg.serverUrl}/send`, { to, body }, cfg.token);
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 postJson(`${cfg.serverUrl}/verify-password`, { password }, cfg.token);
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: process.env.CHAT_MA_SERVER || 'http://localhost:3000'
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 = { ...current, ...partial };
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;
@@ -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') + '/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.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
- if (req.method === 'POST' && req.url === '/register') {
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' && req.url === '/login') {
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' && req.url === '/send') {
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' && req.url === '/verify-password') {
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(`chat-ma server listening on ${config.port}\n`);
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 !== '/ws') {
26
+ if (getPathname(req.url) !== wsPath()) {
17
27
  socket.destroy();
18
28
  return;
19
29
  }