zypher-chat 3.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.
Files changed (2) hide show
  1. package/index.js +1640 -0
  2. package/package.json +15 -0
package/index.js ADDED
@@ -0,0 +1,1640 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const http = require('http');
5
+ const crypto = require('crypto');
6
+ const readline = require('readline');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+ const { spawn, execSync } = require('child_process');
11
+
12
+ // ── ANSI helpers ──────────────────────────────────────────────────────────────
13
+ const TTY = !!process.stdout.isTTY;
14
+ const E = '\x1b[';
15
+
16
+ const c = {
17
+ bold: s => TTY ? `${E}1m${s}${E}0m` : s,
18
+ dim: s => TTY ? `${E}2m${s}${E}0m` : s,
19
+ red: s => TTY ? `${E}31m${s}${E}0m` : s,
20
+ green: s => TTY ? `${E}32m${s}${E}0m` : s,
21
+ yellow: s => TTY ? `${E}33m${s}${E}0m` : s,
22
+ cyan: s => TTY ? `${E}36m${s}${E}0m` : s,
23
+ white: s => TTY ? `${E}37m${s}${E}0m` : s,
24
+ gray: s => TTY ? `${E}90m${s}${E}0m` : s,
25
+ brightRed: s => TTY ? `${E}91m${s}${E}0m` : s,
26
+ brightGreen: s => TTY ? `${E}92m${s}${E}0m` : s,
27
+ brightCyan: s => TTY ? `${E}96m${s}${E}0m` : s,
28
+ bgRed: s => TTY ? `${E}41m${s}${E}0m` : s,
29
+ bgGreen: s => TTY ? `${E}42m${s}${E}0m` : s,
30
+ bgYellow: s => TTY ? `${E}43m${s}${E}0m` : s,
31
+ magenta: s => TTY ? `${E}35m${s}${E}0m` : s,
32
+ };
33
+
34
+ // ── Spinner ───────────────────────────────────────────────────────────────────
35
+ const FRAMES = TTY ? ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'] : ['-','\\','|','/'];
36
+
37
+ function spin(msg) {
38
+ let i = 0, text = msg, timer = null;
39
+ if (TTY) {
40
+ timer = setInterval(() => {
41
+ process.stdout.write(`\r ${c.cyan(FRAMES[i++ % FRAMES.length])} ${c.dim(text)}`);
42
+ }, 80);
43
+ } else {
44
+ process.stdout.write(` >> ${text}\n`);
45
+ }
46
+ const clear = () => {
47
+ if (timer) { clearInterval(timer); timer = null; }
48
+ if (TTY) process.stdout.write('\r\x1b[K');
49
+ };
50
+ return {
51
+ update(t) { text = t; },
52
+ ok(m) { clear(); process.stdout.write(` ${c.brightGreen('✔')} ${m || text}\n`); },
53
+ fail(m) { clear(); process.stdout.write(` ${c.brightRed('✖')} ${m || text}\n`); },
54
+ stop() { clear(); },
55
+ };
56
+ }
57
+
58
+ // ── Box drawing ───────────────────────────────────────────────────────────────
59
+ function stripAnsi(s) { return s.replace(/\x1b\[[0-9;]*m/g, ''); }
60
+
61
+ function box(title, lines) {
62
+ const rawTitle = stripAnsi(title);
63
+ const rawLines = lines.map(l => ({ raw: stripAnsi(l), col: l }));
64
+ const contentW = Math.max(rawTitle.length, ...rawLines.map(l => l.raw.length), 30);
65
+
66
+ const dim = s => TTY ? c.dim(s) : s;
67
+ const dash = n => dim('─'.repeat(Math.max(0, n)));
68
+
69
+ // top: ╭─ Title ──────╮ (between corners = contentW + 4)
70
+ // 1(─) + 1( ) + rawTitle.length + 1( ) + topFill = contentW + 4
71
+ const topFill = Math.max(1, contentW - rawTitle.length + 1);
72
+ const top = ` ${dim('╭')}${dash(1)} ${TTY ? c.bold(c.white(title)) : title} ${dash(topFill)}${dim('╮')}`;
73
+ const bot = ` ${dim('╰')}${dash(contentW + 4)}${dim('╯')}`;
74
+ const rows = rawLines.map(({ raw, col }) => {
75
+ const pad = contentW - raw.length;
76
+ return ` ${dim('│')} ${col}${' '.repeat(pad)} ${dim('│')}`;
77
+ });
78
+ return [top, ...rows, bot].join('\n');
79
+ }
80
+
81
+ // ── Status badge ──────────────────────────────────────────────────────────────
82
+ function badge(type) {
83
+ const map = {
84
+ running: () => c.bgGreen(c.bold(' RUNNING ')),
85
+ stopped: () => c.bgRed(c.bold(' STOPPED ')),
86
+ ok: () => c.bgGreen(c.bold(' OK ')),
87
+ error: () => c.bgRed(c.bold(' ERROR ')),
88
+ warn: () => c.bgYellow(c.bold(' WARN ')),
89
+ };
90
+ return TTY ? (map[type] || map.warn)() : `[${type.toUpperCase()}]`;
91
+ }
92
+
93
+ // ── Horizontal rule ───────────────────────────────────────────────────────────
94
+ function rule(label) {
95
+ const cols = Math.min(process.stdout.columns || 72, 72);
96
+ const mid = label ? ` ${label} ` : '';
97
+ const side = Math.max(2, Math.floor((cols - mid.length - 4) / 2));
98
+ const left = c.dim('─'.repeat(side));
99
+ const right = c.dim('─'.repeat(side));
100
+ const center = mid ? c.bold(c.cyan(mid)) : '';
101
+ return ` ${left}${center}${right}`;
102
+ }
103
+
104
+ // ── Config paths ──────────────────────────────────────────────────────────────
105
+ const CONFIG_DIR = path.join(os.homedir(), '.zypher');
106
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
107
+ const SERVER_PID = path.join(CONFIG_DIR, 'server.pid');
108
+ const SERVER_LOG = path.join(CONFIG_DIR, 'server.log');
109
+
110
+ function ensureConfigDir() {
111
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
112
+ }
113
+
114
+ function loadConfig() {
115
+ try { ensureConfigDir(); return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); }
116
+ catch { return { servers: {}, accounts: {} }; }
117
+ }
118
+
119
+ function saveConfig(cfg) {
120
+ ensureConfigDir();
121
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
122
+ }
123
+
124
+ // ── Readline ──────────────────────────────────────────────────────────────────
125
+ let rl;
126
+ function getRL() {
127
+ if (!rl || rl.closed) rl = readline.createInterface({ input: process.stdin, output: process.stdout });
128
+ return rl;
129
+ }
130
+
131
+ function ask(prompt) {
132
+ return new Promise(resolve => getRL().question(prompt, resolve));
133
+ }
134
+
135
+ function askPassword(prompt) {
136
+ return new Promise(resolve => {
137
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return getRL().question(prompt, resolve);
138
+
139
+ process.stdout.write(prompt);
140
+ let input = '';
141
+ let done = false;
142
+
143
+ // Pause readline so it doesn't consume raw keystrokes
144
+ if (rl) rl.pause();
145
+
146
+ const onData = buf => {
147
+ if (done) return;
148
+ for (const b of buf.toString('utf8')) {
149
+ if (b === '\r' || b === '\n') {
150
+ done = true;
151
+ process.stdin.setRawMode(false);
152
+ process.stdin.removeListener('data', onData);
153
+ process.stdout.write('\n');
154
+ // Resume rl in a setImmediate so the '\n' is flushed to the
155
+ // terminal BEFORE readline writes the next prompt — this
156
+ // prevents line bleed on Windows cmd/PowerShell
157
+ setImmediate(() => { if (rl) rl.resume(); resolve(input); });
158
+ return;
159
+ }
160
+ if (b === '\u0003') { process.stdin.setRawMode(false); process.stdout.write('\n'); process.exit(0); }
161
+ if (b === '\u007F' || b === '\b') {
162
+ if (input.length) { input = input.slice(0, -1); process.stdout.write('\b \b'); }
163
+ } else if (b >= ' ') {
164
+ input += b;
165
+ process.stdout.write(c.dim('•'));
166
+ }
167
+ }
168
+ };
169
+ try {
170
+ process.stdin.setRawMode(true);
171
+ process.stdin.setEncoding('utf8');
172
+ process.stdin.resume();
173
+ process.stdin.on('data', onData);
174
+ } catch { if (rl) rl.resume(); getRL().question(prompt, resolve); }
175
+ });
176
+ }
177
+
178
+ // ── HTTP API ──────────────────────────────────────────────────────────────────
179
+ function apiRequest(serverUrl, method, urlPath, body, token) {
180
+ return new Promise((resolve, reject) => {
181
+ let parsed;
182
+ try { parsed = new URL(serverUrl); }
183
+ catch { return reject(new Error(`Invalid server URL: ${serverUrl}`)); }
184
+
185
+ const hostname = parsed.hostname;
186
+ const port = parseInt(parsed.port || (parsed.protocol === 'https:' ? '443' : '80'), 10);
187
+ const payload = body ? JSON.stringify(body) : null;
188
+ const req = http.request({
189
+ hostname, port, path: urlPath, method,
190
+ headers: {
191
+ 'Content-Type': 'application/json',
192
+ ...(token && { Authorization: `Bearer ${token}` }),
193
+ ...(payload && { 'Content-Length': Buffer.byteLength(payload) }),
194
+ },
195
+ }, res => {
196
+ let raw = '';
197
+ res.on('data', d => raw += d);
198
+ res.on('end', () => {
199
+ try {
200
+ const json = JSON.parse(raw);
201
+ if (res.statusCode >= 400) reject(new Error(json.error || raw));
202
+ else resolve(json);
203
+ } catch { reject(new Error(raw || `HTTP ${res.statusCode}`)); }
204
+ });
205
+ });
206
+ req.setTimeout(8000, () => req.destroy(new Error('Connection timed out')));
207
+ req.on('error', reject);
208
+ if (payload) req.write(payload);
209
+ req.end();
210
+ });
211
+ }
212
+
213
+ // ── Crypto ────────────────────────────────────────────────────────────────────
214
+ function generateKeyPairs() {
215
+ const identity = crypto.generateKeyPairSync('ed25519', {
216
+ publicKeyEncoding: { type: 'spki', format: 'der' },
217
+ privateKeyEncoding: { type: 'pkcs8', format: 'der' },
218
+ });
219
+ const preKey = crypto.generateKeyPairSync('x25519', {
220
+ publicKeyEncoding: { type: 'spki', format: 'der' },
221
+ privateKeyEncoding: { type: 'pkcs8', format: 'der' },
222
+ });
223
+ return {
224
+ identityPubKey: identity.publicKey.toString('base64'),
225
+ identityPrivKey: identity.privateKey.toString('base64'),
226
+ preKeyPub: preKey.publicKey.toString('base64'),
227
+ preKeyPriv: preKey.privateKey.toString('base64'),
228
+ };
229
+ }
230
+
231
+ function ecdhKey(privB64, pubB64) {
232
+ const priv = crypto.createPrivateKey({ key: Buffer.from(privB64, 'base64'), format: 'der', type: 'pkcs8' });
233
+ const pub = crypto.createPublicKey ({ key: Buffer.from(pubB64, 'base64'), format: 'der', type: 'spki' });
234
+ const dh = crypto.diffieHellman({ privateKey: priv, publicKey: pub });
235
+ // HKDF-SHA256: proper KDF with domain separation — stronger than raw SHA256
236
+ return Buffer.from(crypto.hkdfSync('sha256', dh, Buffer.alloc(32), Buffer.from('zypher-x25519-v1'), 32));
237
+ }
238
+
239
+ function encryptMsg(plaintext, keyBuf, aad = '') {
240
+ const nonce = crypto.randomBytes(12);
241
+ const cipher = crypto.createCipheriv('aes-256-gcm', keyBuf, nonce);
242
+ if (aad) cipher.setAAD(Buffer.from(aad));
243
+ const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
244
+ const tag = cipher.getAuthTag();
245
+ return { ciphertext: Buffer.concat([enc, tag]).toString('base64'), nonce: nonce.toString('base64') };
246
+ }
247
+
248
+ function decryptMsg(ctB64, nonceB64, keyBuf, aad = '') {
249
+ const buf = Buffer.from(ctB64, 'base64');
250
+ const nonce = Buffer.from(nonceB64, 'base64');
251
+ const tag = buf.slice(buf.length - 16);
252
+ const enc = buf.slice(0, buf.length - 16);
253
+ const d = crypto.createDecipheriv('aes-256-gcm', keyBuf, nonce);
254
+ if (aad) d.setAAD(Buffer.from(aad));
255
+ d.setAuthTag(tag);
256
+ return Buffer.concat([d.update(enc), d.final()]).toString('utf8');
257
+ }
258
+
259
+ // Ed25519 message signing — proves authorship, prevents server from forging or tampering
260
+ function signMsg(data, privB64) {
261
+ const key = crypto.createPrivateKey({ key: Buffer.from(privB64, 'base64'), format: 'der', type: 'pkcs8' });
262
+ return crypto.sign(null, Buffer.from(data), key).toString('base64');
263
+ }
264
+
265
+ function verifyMsg(data, sigB64, pubB64) {
266
+ try {
267
+ const key = crypto.createPublicKey({ key: Buffer.from(pubB64, 'base64'), format: 'der', type: 'spki' });
268
+ return crypto.verify(null, Buffer.from(data), key, Buffer.from(sigB64, 'base64'));
269
+ } catch { return false; }
270
+ }
271
+
272
+ // Per-session identity key cache — populated when keys are fetched, used for signature verification
273
+ const keysCache = new Map();
274
+
275
+ // ── Notifications & unread tracking ────────────────────────────────────────────
276
+ const currentChat = { type: null, with: null }; // 'dm' | 'group' | null
277
+ const unreadBuf = { dms: {}, groups: {} }; // buffered cross-chat messages
278
+
279
+ function notify(from, preview) {
280
+ process.stdout.write('\x07'); // terminal bell
281
+ if (TTY) {
282
+ const orig = process.title;
283
+ process.title = `[!] ${from}: ${String(preview || '').slice(0, 40)}`;
284
+ setTimeout(() => { try { process.title = orig; } catch {} }, 4000);
285
+ }
286
+ }
287
+
288
+ function showNotifBanner(from, groupName) {
289
+ readline.clearLine(process.stdout, 0);
290
+ readline.cursorTo(process.stdout, 0);
291
+ const who = groupName
292
+ ? `${c.bold(c.green(from))} ${c.dim('in')} ${c.bold(c.magenta('@' + groupName))}`
293
+ : c.bold(c.green(from));
294
+ const total = Object.values(unreadBuf.dms).reduce((s, a) => s + a.length, 0)
295
+ + Object.values(unreadBuf.groups).reduce((s, a) => s + a.length, 0);
296
+ process.stdout.write(
297
+ ` ${c.bgYellow(c.bold(' NEW '))} ${who} ${c.dim('wrote')}` +
298
+ (total > 1 ? c.dim(` · ${total} unread`) : '') + '\n' +
299
+ c.dim(' /inbox to read\n\n')
300
+ );
301
+ if (currentChat.type !== null) getRL().prompt(true);
302
+ }
303
+
304
+ // ── Group crypto helpers ──────────────────────────────────────────────────────
305
+ // Encrypt a 32-byte group key for a specific user using their X25519 pre-key.
306
+ // Returns { ratchetKey, encKey, nonce } — all base64.
307
+ function encryptGroupKeyForUser(groupKeyBuf, theirPreKeyPub) {
308
+ const eph = crypto.generateKeyPairSync('x25519', {
309
+ publicKeyEncoding: { type: 'spki', format: 'der' },
310
+ privateKeyEncoding: { type: 'pkcs8', format: 'der' },
311
+ });
312
+ const ratchetKey = eph.publicKey.toString('base64');
313
+ const ratchetPriv = eph.privateKey.toString('base64');
314
+ const aesKey = ecdhKey(ratchetPriv, theirPreKeyPub);
315
+ const { ciphertext: encKey, nonce } = encryptMsg(groupKeyBuf.toString('hex'), aesKey, 'zypher-group-key-bundle-v1');
316
+ return { ratchetKey, encKey, nonce };
317
+ }
318
+
319
+ // Decrypt a group key bundle, returning the raw 32-byte Buffer.
320
+ function decryptGroupKey(bundle, myPreKeyPriv) {
321
+ const aesKey = ecdhKey(myPreKeyPriv, bundle.ratchetKey);
322
+ const keyHex = decryptMsg(bundle.encKey, bundle.nonce, aesKey, 'zypher-group-key-bundle-v1');
323
+ return Buffer.from(keyHex, 'hex');
324
+ }
325
+
326
+ // ── Promisified spawn ─────────────────────────────────────────────────────────
327
+ const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
328
+
329
+ function spawnAsync(cmd, args, opts) {
330
+ return new Promise((resolve, reject) => {
331
+ // On Windows, .cmd/.bat files must be run through the shell
332
+ const isWin = process.platform === 'win32';
333
+ const child = spawn(cmd, args, { stdio: 'pipe', shell: isWin, ...opts });
334
+ let stderr = '';
335
+ if (child.stderr) child.stderr.on('data', d => { stderr += d.toString(); });
336
+ child.on('error', reject);
337
+ child.on('close', code => code === 0 ? resolve() : reject(new Error(stderr.trim() || `${cmd} exited ${code}`)));
338
+ });
339
+ }
340
+
341
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
342
+
343
+ // ── Banner ────────────────────────────────────────────────────────────────────
344
+ function printBanner() {
345
+ const art = [
346
+ ' ______ _ ',
347
+ ' |___ / | | ',
348
+ " / / _ _ _ | |__ ___ _ __ ",
349
+ " / / | | | | \\| '_ \\ / _ \\ '__|",
350
+ ' / /__| |_| | |_| | | | __/ | ',
351
+ ' /_____\\__, |\\__,_| |_|\\___|_| ',
352
+ ];
353
+ process.stdout.write('\n');
354
+ for (const line of art) process.stdout.write(c.cyan(line) + '\n');
355
+ process.stdout.write(c.dim(' __/ |') + c.bold(c.white(' ghost e2ee cli ')) + '\n');
356
+ process.stdout.write(c.dim(' |___/') + c.gray(' v3 · zero-knowledge · forward-secrecy') + '\n\n');
357
+ }
358
+
359
+ function printHelp() {
360
+ printBanner();
361
+ process.stdout.write(rule('commands') + '\n\n');
362
+ const cmds = [
363
+ [c.bold(c.cyan('zypher server quickstart')), 'Clone, configure & launch a Zypher server daemon'],
364
+ [c.bold(c.cyan('zypher server stop')), 'Stop the running server daemon'],
365
+ [c.bold(c.cyan('zypher server status')), 'Show server daemon status'],
366
+ [c.bold(c.cyan('zypher new')), 'Add a server and register / login'],
367
+ [c.bold(c.cyan('zypher <server> [user]')), 'Open E2EE chat on a saved server'],
368
+ [c.bold(c.cyan('zypher settings')), 'Manage servers, accounts, ghost mode'],
369
+ [c.bold(c.cyan('zypher help')), 'Show this help'],
370
+ ];
371
+ for (const [cmd, desc] of cmds) {
372
+ process.stdout.write(` ${cmd}\n`);
373
+ process.stdout.write(` ${c.dim(desc)}\n\n`);
374
+ }
375
+ process.stdout.write(rule('examples') + '\n\n');
376
+ for (const ex of ['zypher server quickstart', 'zypher new', 'zypher local', 'zypher local alice', 'zypher settings'])
377
+ process.stdout.write(` ${c.dim('$')} ${c.green(ex)}\n`);
378
+ process.stdout.write('\n');
379
+ }
380
+
381
+ // ── zypher server quickstart ──────────────────────────────────────────────────
382
+ async function cmdServerQuickstart() {
383
+ printBanner();
384
+ process.stdout.write(rule('server quickstart') + '\n\n');
385
+ process.stdout.write(c.dim(' Press Enter to keep defaults shown in [ ].\n\n'));
386
+
387
+ const portRaw = (await ask(` ${c.bold('Port')} ${c.dim('[3000]')} : `)).trim();
388
+ const masterPass = await askPassword(` ${c.bold('Master password')} : `);
389
+ const rlRaw = (await ask(` ${c.bold('Rate limit')} (req/min) ${c.dim('[100]')} : `)).trim();
390
+ const bodyRaw = (await ask(` ${c.bold('Max body size')} ${c.dim('[1mb]')} : `)).trim();
391
+ process.stdout.write('\n');
392
+
393
+ if (!masterPass) {
394
+ process.stdout.write(` ${c.brightRed('✖')} Master password cannot be empty.\n\n`);
395
+ process.exit(1);
396
+ }
397
+
398
+ const port = Math.max(1, Math.min(65535, parseInt(portRaw || '3000', 10)));
399
+ const rateLimit = Math.max(1, parseInt(rlRaw || '100', 10));
400
+ const maxBodySize = bodyRaw || '1mb';
401
+ const jwtSecret = crypto.randomBytes(32).toString('hex');
402
+
403
+ ensureConfigDir();
404
+
405
+ // ── 1. Check git ──────────────────────────────────────────────────────────
406
+ {
407
+ const s = spin('Checking for git…');
408
+ try { execSync('git --version', { stdio: 'pipe' }); s.ok('git is available'); }
409
+ catch { s.fail('git not found — install git and retry'); process.exit(1); }
410
+ }
411
+
412
+ // ── 2. Clone repo ─────────────────────────────────────────────────────────
413
+ const cloneDir = path.resolve(process.cwd(), 'zypher');
414
+ if (fs.existsSync(cloneDir)) {
415
+ process.stdout.write(`\n ${c.yellow('!')} ${c.bold(cloneDir)} already exists.\n`);
416
+ const ans = (await ask(` ${c.dim('Overwrite? [y/N] : ')}`)).trim().toLowerCase();
417
+ process.stdout.write('\n');
418
+ if (ans !== 'y') { process.stdout.write(' Aborted.\n\n'); process.exit(0); }
419
+ const s = spin('Removing old directory…');
420
+ fs.rmSync(cloneDir, { recursive: true, force: true });
421
+ s.ok('Old directory removed');
422
+ }
423
+
424
+ {
425
+ const s = spin('Cloning real-kijmoshi/zypher from GitHub…');
426
+ try {
427
+ await spawnAsync('git', ['clone', 'https://github.com/real-kijmoshi/zypher', cloneDir]);
428
+ s.ok('Repository cloned');
429
+ } catch (err) { s.fail(`Clone failed: ${err.message}`); process.exit(1); }
430
+ }
431
+
432
+ // ── 3. Remove client folder from clone ────────────────────────────────────
433
+ {
434
+ const s = spin('Removing client folder from clone…');
435
+ const clientDir = path.join(cloneDir, 'client');
436
+ if (fs.existsSync(clientDir)) fs.rmSync(clientDir, { recursive: true, force: true });
437
+ s.ok('Client folder removed');
438
+ }
439
+
440
+ // ── 4. npm install ────────────────────────────────────────────────────────
441
+ const serverDir = path.join(cloneDir, 'server');
442
+ if (!fs.existsSync(serverDir)) {
443
+ process.stdout.write(`\n ${c.brightRed('✖')} No server/ folder in cloned repo.\n\n`);
444
+ process.exit(1);
445
+ }
446
+
447
+ {
448
+ const s = spin('Installing server dependencies…');
449
+ try {
450
+ await spawnAsync(NPM, ['install', '--prefer-offline', '--no-audit', '--no-fund'], { cwd: serverDir });
451
+ s.ok('Dependencies installed');
452
+ } catch (err) { s.fail(`npm install failed: ${err.message}`); process.exit(1); }
453
+ }
454
+
455
+ // ── 5. Write server config ────────────────────────────────────────────────
456
+ const serverCfgPath = path.join(CONFIG_DIR, 'server-config.json');
457
+ const cfgObj = { port, masterPassword: masterPass, jwtSecret, rateLimit, maxBodySize, serverDir };
458
+ fs.writeFileSync(serverCfgPath, JSON.stringify(cfgObj, null, 2), { mode: 0o600 });
459
+
460
+ // Also write a .env in the server directory so dotenv-based servers work
461
+ const envContent = [
462
+ `PORT=${port}`,
463
+ `MASTER_PASSWORD=${masterPass}`,
464
+ `JWT_SECRET=${jwtSecret}`,
465
+ `RATE_LIMIT=${rateLimit}`,
466
+ `MAX_BODY_SIZE=${maxBodySize}`,
467
+ ].join('\n') + '\n';
468
+ fs.writeFileSync(path.join(serverDir, '.env'), envContent, { mode: 0o600 });
469
+ process.stdout.write(` ${c.dim('·')} Config written\n`);
470
+
471
+ // ── 6. Kill old daemon ────────────────────────────────────────────────────
472
+ try {
473
+ const oldPid = parseInt(fs.readFileSync(SERVER_PID, 'utf8').trim(), 10);
474
+ process.kill(oldPid, 0);
475
+ process.kill(oldPid);
476
+ process.stdout.write(` ${c.dim('↑')} Stopped previous daemon (PID ${oldPid})\n`);
477
+ } catch { /* not running */ }
478
+
479
+ // ── 7. Daemonize ─────────────────────────────────────────────────────────
480
+ const serverScript = path.join(serverDir, 'index.js');
481
+ if (!fs.existsSync(serverScript)) {
482
+ process.stdout.write(`\n ${c.brightRed('✖')} server/index.js not found in clone.\n\n`);
483
+ process.exit(1);
484
+ }
485
+
486
+ const logFd = fs.openSync(SERVER_LOG, 'a');
487
+ const child = spawn(process.execPath, [serverScript], {
488
+ detached: true,
489
+ stdio: ['ignore', logFd, logFd],
490
+ env: { ...process.env, ZYPHER_CONFIG: serverCfgPath },
491
+ });
492
+ child.unref();
493
+ fs.closeSync(logFd);
494
+ fs.writeFileSync(SERVER_PID, String(child.pid));
495
+
496
+ // ── 8. Health check ───────────────────────────────────────────────────────
497
+ {
498
+ const s = spin('Waiting for server to come online…');
499
+ await sleep(900);
500
+ try {
501
+ await apiRequest(`http://localhost:${port}`, 'GET', '/health', null, null);
502
+ s.ok(`Server is online`);
503
+ } catch { s.fail('Server started but /health failed — check the log'); }
504
+ }
505
+
506
+ process.stdout.write('\n');
507
+ const aliasInput = (await ask(` ${c.bold('Save server as alias')} ${c.dim('[local]')} : `)).trim() || 'local';
508
+ process.stdout.write('\n');
509
+
510
+ const cfg = loadConfig();
511
+ cfg.servers[aliasInput] = { url: `http://localhost:${port}`, alias: aliasInput };
512
+ saveConfig(cfg);
513
+
514
+ process.stdout.write(box('server started', [
515
+ `${c.dim('Status')} ${badge('running')}`,
516
+ `${c.dim('PID ')} ${c.bold(String(child.pid))}`,
517
+ `${c.dim('Port ')} ${c.bold(String(port))}`,
518
+ `${c.dim('Alias ')} ${c.bold(c.cyan(aliasInput))}`,
519
+ `${c.dim('Dir ')} ${c.gray(cloneDir)}`,
520
+ `${c.dim('Log ')} ${c.gray(SERVER_LOG)}`,
521
+ '',
522
+ `Connect ${c.bold(c.green(`zypher ${aliasInput}`))}`,
523
+ `Stop ${c.bold(c.green('zypher server stop'))}`,
524
+ ]) + '\n\n');
525
+
526
+ // Offer to create the first account right now
527
+ const createNow = (await ask(` ${c.bold('Create first account on this server?')} ${c.dim('[Y/n]')} : `)).trim().toLowerCase();
528
+ process.stdout.write('\n');
529
+ if (createNow !== 'n') {
530
+ await doRegister(cfg, aliasInput, `http://localhost:${port}`);
531
+ }
532
+
533
+ getRL().close();
534
+ }
535
+
536
+ // ── zypher server stop ────────────────────────────────────────────────────────
537
+ function cmdServerStop() {
538
+ try {
539
+ const pid = parseInt(fs.readFileSync(SERVER_PID, 'utf8').trim(), 10);
540
+ process.kill(pid);
541
+ fs.unlinkSync(SERVER_PID);
542
+ process.stdout.write(`\n ${c.brightGreen('✔')} Server (PID ${c.bold(String(pid))}) ${badge('stopped')}\n\n`);
543
+ } catch {
544
+ process.stdout.write(`\n ${c.dim('·')} No running server found.\n\n`);
545
+ }
546
+ }
547
+
548
+ // ── zypher server status ──────────────────────────────────────────────────────
549
+ function cmdServerStatus() {
550
+ try {
551
+ const pid = parseInt(fs.readFileSync(SERVER_PID, 'utf8').trim(), 10);
552
+ process.kill(pid, 0);
553
+ let port = '?';
554
+ try { port = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, 'server-config.json'), 'utf8')).port; } catch {}
555
+ process.stdout.write(`\n ${badge('running')} PID ${c.bold(String(pid))} · port ${c.bold(String(port))}\n\n`);
556
+ } catch {
557
+ process.stdout.write(`\n ${badge('stopped')} Server is not running.\n\n`);
558
+ }
559
+ }
560
+
561
+ // ── zypher new ────────────────────────────────────────────────────────────────
562
+ async function cmdNew() {
563
+ printBanner();
564
+ process.stdout.write(rule('add server') + '\n\n');
565
+
566
+ const cfg = loadConfig();
567
+ let urlRaw = (await ask(` ${c.bold('Server URL')} ${c.dim('[http://localhost:3000]')} : `)).trim() || 'http://localhost:3000';
568
+
569
+ // Normalize: strip trailing slashes, add http:// if no scheme given,
570
+ // and collapse accidental double-scheme (e.g. http://http://...)
571
+ urlRaw = urlRaw.replace(/\/+$/, '');
572
+ if (!/^https?:\/\//i.test(urlRaw)) urlRaw = 'http://' + urlRaw;
573
+ // Collapse double scheme
574
+ urlRaw = urlRaw.replace(/^(https?:\/\/)+/i, m => m.slice(0, m.indexOf('://') + 3));
575
+ const urlInput = urlRaw;
576
+
577
+ const alias = (await ask(` ${c.bold('Alias')} : `)).trim();
578
+ process.stdout.write('\n');
579
+
580
+ if (!alias) {
581
+ process.stdout.write(` ${c.brightRed('✖')} Alias cannot be empty.\n\n`);
582
+ getRL().close(); return;
583
+ }
584
+ try { new URL(urlInput); } catch {
585
+ process.stdout.write(` ${c.brightRed('✖')} Invalid URL format.\n\n`);
586
+ getRL().close(); return;
587
+ }
588
+
589
+ const s = spin(`Connecting to ${urlInput}…`);
590
+ try {
591
+ await apiRequest(urlInput, 'GET', '/health', null, null);
592
+ s.ok(`${c.brightGreen('Reachable')} ${c.gray(urlInput)}`);
593
+ } catch (err) {
594
+ const offline = err.message.includes('timed out') || err.message.includes('ECONNREFUSED');
595
+ s.fail(offline ? `Offline — server unreachable, saved anyway` : `${err.message}`);
596
+ }
597
+
598
+ cfg.servers[alias] = { url: urlInput, alias };
599
+ saveConfig(cfg);
600
+
601
+ process.stdout.write('\n');
602
+ process.stdout.write(` ${c.bold(c.cyan('1'))} Register a new account on this server\n`);
603
+ process.stdout.write(` ${c.bold(c.cyan('2'))} Login with an existing account\n`);
604
+ process.stdout.write(` ${c.bold(c.cyan('3'))} Save and quit\n`);
605
+ const choice = (await ask(`\n ${c.dim('›')} `)).trim();
606
+ process.stdout.write('\n');
607
+
608
+ if (choice === '1') await doRegister(cfg, alias, urlInput);
609
+ else if (choice === '2') await doLogin(cfg, alias, urlInput);
610
+ else process.stdout.write(` ${c.dim('·')} Saved "${alias}". Use ${c.cyan(`zypher ${alias}`)} to connect.\n\n`);
611
+
612
+ getRL().close();
613
+ }
614
+
615
+ // ── Register ──────────────────────────────────────────────────────────────────
616
+ async function doRegister(cfg, serverAlias, serverUrl) {
617
+ process.stdout.write(rule('register') + '\n\n');
618
+ const username = (await ask(` ${c.bold('Username')} : `)).trim();
619
+ const password = await askPassword(` ${c.bold('Password')} : `);
620
+ const master = await askPassword(` ${c.bold('Master password')} : `);
621
+ process.stdout.write('\n');
622
+
623
+ if (!username || !password || !master) {
624
+ process.stdout.write(` ${c.brightRed('✖')} All fields are required.\n\n`); return;
625
+ }
626
+
627
+ const keys = generateKeyPairs();
628
+ const s = spin('Registering…');
629
+ try {
630
+ await apiRequest(serverUrl, 'POST', '/register', {
631
+ username, password, masterPassword: master,
632
+ identityKey: keys.identityPubKey, preKey: keys.preKeyPub,
633
+ });
634
+ s.stop();
635
+ } catch (err) { s.fail(`Registration failed: ${err.message}`); return; }
636
+
637
+ let token = '';
638
+ try { token = (await apiRequest(serverUrl, 'POST', '/login', { username, password })).token; } catch {}
639
+
640
+ if (!cfg.accounts[serverAlias]) cfg.accounts[serverAlias] = {};
641
+ cfg.accounts[serverAlias][username] = { keys, token };
642
+ saveConfig(cfg);
643
+
644
+ process.stdout.write(box('registered', [
645
+ `${c.dim('User ')} ${c.bold(c.cyan(username))}`,
646
+ `${c.dim('Server')} ${c.bold(serverAlias)}`,
647
+ '',
648
+ `Connect ${c.bold(c.green(`zypher ${serverAlias} ${username}`))}`,
649
+ ]) + '\n\n');
650
+ }
651
+
652
+ // ── Login ─────────────────────────────────────────────────────────────────────
653
+ async function doLogin(cfg, serverAlias, serverUrl) {
654
+ process.stdout.write(rule('login') + '\n\n');
655
+ const username = (await ask(` ${c.bold('Username')} : `)).trim();
656
+ const password = await askPassword(` ${c.bold('Password')} : `);
657
+ process.stdout.write('\n');
658
+
659
+ const s = spin('Authenticating…');
660
+ let res;
661
+ try { res = await apiRequest(serverUrl, 'POST', '/login', { username, password }); s.stop(); }
662
+ catch (err) { s.fail(`Login failed: ${err.message}`); return; }
663
+
664
+ if (!cfg.accounts[serverAlias]) cfg.accounts[serverAlias] = {};
665
+ const existing = cfg.accounts[serverAlias][username] || {};
666
+
667
+ // If there are no local private keys for this account (e.g. first login on
668
+ // this machine), generate a fresh key pair and push the public keys to the
669
+ // server so future messages can be encrypted to us.
670
+ let keys = existing.keys;
671
+ if (!keys) {
672
+ process.stdout.write(`\n ${c.yellow('!')} No local keys — generating new key pair and uploading to server.\n`);
673
+ process.stdout.write(c.dim(' Messages sent before this re-key cannot be decrypted on this device.\n\n'));
674
+ keys = generateKeyPairs();
675
+ const sk = spin('Uploading new public keys…');
676
+ try {
677
+ await apiRequest(serverUrl, 'PUT', '/keys', {
678
+ identityKey: keys.identityPubKey,
679
+ preKey: keys.preKeyPub,
680
+ }, res.token);
681
+ sk.ok('Public keys updated on server');
682
+ } catch (e) {
683
+ sk.fail(`Re-key failed: ${e.message}`);
684
+ }
685
+ }
686
+
687
+ cfg.accounts[serverAlias][username] = { ...existing, keys, token: res.token };
688
+ saveConfig(cfg);
689
+
690
+ process.stdout.write(`\n ${c.brightGreen('✔')} Logged in as ${c.bold(c.cyan(username))} on ${c.bold(serverAlias)}\n`);
691
+ process.stdout.write(` ${c.dim('Connect:')} ${c.green(`zypher ${serverAlias} ${username}`)}\n\n`);
692
+ }
693
+
694
+ // ── zypher <server> [user] ────────────────────────────────────────────────────
695
+ async function cmdConnect(serverAlias, usernameArg) {
696
+ const cfg = loadConfig();
697
+ const server = cfg.servers[serverAlias];
698
+
699
+ if (!server) {
700
+ process.stdout.write(`\n ${c.brightRed('✖')} Unknown server ${c.bold(`"${serverAlias}"`)}\n`);
701
+ process.stdout.write(` ${c.dim('Use')} ${c.cyan('zypher new')} ${c.dim('to add a server.')}\n\n`);
702
+ process.exit(1);
703
+ }
704
+
705
+ let username = usernameArg;
706
+ const accounts = Object.keys(cfg.accounts?.[serverAlias] || {});
707
+
708
+ if (!username) {
709
+ if (!accounts.length) {
710
+ process.stdout.write(`\n ${c.brightRed('✖')} No accounts for "${serverAlias}". Use ${c.cyan('zypher new')}.\n\n`);
711
+ process.exit(1);
712
+ }
713
+ if (accounts.length === 1) {
714
+ username = accounts[0];
715
+ } else {
716
+ printBanner();
717
+ process.stdout.write(rule(`accounts on ${serverAlias}`) + '\n\n');
718
+ accounts.forEach((u, i) => process.stdout.write(` ${c.bold(c.cyan(String(i + 1)))} ${u}\n`));
719
+ const idx = parseInt((await ask(`\n ${c.dim('›')} `)).trim(), 10) - 1;
720
+ username = accounts[Math.max(0, Math.min(idx, accounts.length - 1))];
721
+ process.stdout.write('\n');
722
+ }
723
+ }
724
+
725
+ const account = cfg.accounts?.[serverAlias]?.[username];
726
+ if (!account) {
727
+ process.stdout.write(`\n ${c.brightRed('✖')} No stored account "${username}" on "${serverAlias}".\n\n`);
728
+ process.exit(1);
729
+ }
730
+
731
+ let { token, keys } = account;
732
+
733
+ const tryRelogin = async () => {
734
+ const password = await askPassword(` ${c.bold('Password')} : `);
735
+ process.stdout.write('\n');
736
+ const res = await apiRequest(server.url, 'POST', '/login', { username, password });
737
+ token = res.token;
738
+ cfg.accounts[serverAlias][username].token = token;
739
+ saveConfig(cfg);
740
+ };
741
+
742
+ if (token) {
743
+ try { await apiRequest(server.url, 'GET', `/keys/${username}`, null, token); }
744
+ catch (err) {
745
+ if (/invalid token|missing token|auth/i.test(err.message)) {
746
+ process.stdout.write(` ${c.yellow('!')} Session expired — re-enter password.\n`);
747
+ try { await tryRelogin(); }
748
+ catch (e) { process.stdout.write(` ${c.brightRed('✖')} Re-login: ${e.message}\n\n`); process.exit(1); }
749
+ }
750
+ }
751
+ } else {
752
+ process.stdout.write(` ${c.yellow('!')} No session — please log in.\n`);
753
+ try { await tryRelogin(); }
754
+ catch (e) { process.stdout.write(` ${c.brightRed('✖')} Login: ${e.message}\n\n`); process.exit(1); }
755
+ }
756
+
757
+ if (!keys) {
758
+ process.stdout.write(`\n ${c.brightRed('✖')} No local keys. Register on this machine via ${c.cyan('zypher new')}.\n\n`);
759
+ process.exit(1);
760
+ }
761
+
762
+ await runChat(server.url, serverAlias, username, token, keys);
763
+ }
764
+
765
+ // ── Chat ──────────────────────────────────────────────────────────────────────
766
+ async function runChat(serverUrl, serverAlias, username, token, myKeys) {
767
+ // Seed the key cache with our own identity key
768
+ keysCache.set(username, myKeys.identityPubKey);
769
+ let ioClient;
770
+ try { ioClient = require('socket.io-client'); }
771
+ catch {
772
+ process.stdout.write(`\n ${c.brightRed('✖')} socket.io-client not installed.\n Run: ${c.cyan('npm install')} inside the client/ folder.\n\n`);
773
+ process.exit(1);
774
+ }
775
+
776
+ // Connect socket first so background notifications work immediately.
777
+ const socket = ioClient(serverUrl, { auth: { token } });
778
+ socket.on('connect_error', err => process.stdout.write(`\n ${c.brightRed('✖')} Socket: ${err.message}\n`));
779
+
780
+ // Always-on: show group invite notifications in any chat mode.
781
+ socket.on('group_invite', ({ groupName, invitedBy }) => {
782
+ notify(invitedBy, `invited you to @${groupName}`);
783
+ readline.clearLine(process.stdout, 0);
784
+ readline.cursorTo(process.stdout, 0);
785
+ process.stdout.write(
786
+ ` ${c.bgYellow(c.bold(' GROUP INVITE '))} ${c.bold(c.green(invitedBy))} ` +
787
+ `added you to ${c.bold(c.cyan('@' + groupName))}\n` +
788
+ c.dim(` Chat with: @${groupName} to open it\n\n`)
789
+ );
790
+ if (currentChat.type !== null) getRL().prompt(true);
791
+ });
792
+
793
+ // Background: DMs that arrive while AFK, at the "Chat with" prompt, or in group chat
794
+ socket.on('receive_message', msg => {
795
+ if (currentChat.type === 'dm' && currentChat.with === msg.from) return;
796
+ if (!unreadBuf.dms[msg.from]) unreadBuf.dms[msg.from] = [];
797
+ unreadBuf.dms[msg.from].push(msg);
798
+ notify(msg.from, '…');
799
+ showNotifBanner(msg.from, null);
800
+ });
801
+
802
+ // Background: group messages that arrive while AFK, at the "Chat with" prompt, or in 1:1 chat
803
+ socket.on('receive_group_message', msg => {
804
+ if (msg.from === username) return;
805
+ if (currentChat.type === 'group' && currentChat.with === msg.groupName) return;
806
+ if (!unreadBuf.groups[msg.groupName]) unreadBuf.groups[msg.groupName] = [];
807
+ unreadBuf.groups[msg.groupName].push(msg);
808
+ notify(msg.from, `@${msg.groupName}`);
809
+ showNotifBanner(msg.from, msg.groupName);
810
+ });
811
+
812
+ // ── Lobby ─────────────────────────────────────────────────────────────────
813
+ {
814
+ const w = Math.min(process.stdout.columns || 72, 72);
815
+ process.stdout.write('\n' + c.dim('─'.repeat(w)) + '\n');
816
+ process.stdout.write(
817
+ ` ${c.bold(c.cyan(serverAlias))} ${c.dim('·')} ${c.bold(username)} ` +
818
+ `${c.bgGreen(c.bold(' E2EE '))}\n`
819
+ );
820
+ // Show online users + my groups (parallel)
821
+ let onlineUsers = [];
822
+ let myGroups = [];
823
+ try {
824
+ const [onlineRes, groupsRes] = await Promise.allSettled([
825
+ apiRequest(serverUrl, 'GET', '/online', null, token),
826
+ apiRequest(serverUrl, 'GET', '/groups', null, token),
827
+ ]);
828
+ if (onlineRes.status === 'fulfilled') {
829
+ onlineUsers = (onlineRes.value.online || []).filter(u => u !== username);
830
+ }
831
+ if (groupsRes.status === 'fulfilled') {
832
+ myGroups = groupsRes.value.groups || [];
833
+ }
834
+ } catch { /* non-fatal */ }
835
+
836
+ if (onlineUsers.length) {
837
+ process.stdout.write(
838
+ ` ${c.dim('online:')} ` +
839
+ onlineUsers.map(u => {
840
+ const badge = unreadBuf.dms[u] ? c.bgYellow(c.bold(` ${unreadBuf.dms[u].length} `)) : '';
841
+ return c.bold(c.green('● ')) + c.bold(u) + badge;
842
+ }).join(c.dim(' ')) + '\n'
843
+ );
844
+ }
845
+ // Offline users with pending unread DMs
846
+ const offlineUnread = Object.keys(unreadBuf.dms).filter(u => !onlineUsers.includes(u));
847
+ if (offlineUnread.length) {
848
+ process.stdout.write(
849
+ ` ${c.dim('unread:')} ` +
850
+ offlineUnread.map(u => c.bold(u) + c.bgYellow(c.bold(` ${unreadBuf.dms[u].length} `))).join(c.dim(' ')) + '\n'
851
+ );
852
+ }
853
+ if (myGroups.length) {
854
+ process.stdout.write(
855
+ ` ${c.dim('groups:')} ` +
856
+ myGroups.map(g => {
857
+ const badge = unreadBuf.groups[g.name] ? c.bgYellow(c.bold(` ${unreadBuf.groups[g.name].length} `)) : '';
858
+ return c.bold(c.magenta('@' + g.name)) + badge;
859
+ }).join(c.dim(' ')) + '\n'
860
+ );
861
+ }
862
+ process.stdout.write(c.dim('─'.repeat(w)) + '\n\n');
863
+ }
864
+
865
+ const recipient = (await ask(` ${c.bold('Chat with')} ${c.dim('(name, @group, or leave blank to quit)')} : `)).trim();
866
+ if (!recipient) { socket.disconnect(); process.stdout.write(' Disconnected.\n\n'); process.exit(0); }
867
+
868
+ // Route to group chat when prefixed with @
869
+ if (recipient.startsWith('@')) {
870
+ await runGroupChat(serverUrl, serverAlias, username, token, myKeys, socket, recipient.slice(1));
871
+ return;
872
+ }
873
+
874
+ // ── 1:1 chat ─────────────────────────────────────────────────────────────
875
+ currentChat.type = 'dm';
876
+ currentChat.with = recipient;
877
+
878
+ const s = spin(`Fetching ${recipient}'s keys…`);
879
+ let theirKeys;
880
+ try {
881
+ theirKeys = await apiRequest(serverUrl, 'GET', `/keys/${recipient}`, null, token);
882
+ keysCache.set(recipient, theirKeys.identityKey); // cache for signature verification
883
+ s.ok(`Keys fetched for ${c.bold(recipient)}`);
884
+ } catch (err) { s.fail(`Could not fetch keys for "${recipient}": ${err.message}`); socket.disconnect(); getRL().close(); return; }
885
+
886
+ // Show header + request offline queue once connected (guard against socket
887
+ // already being connected while we waited for user input above).
888
+ const initOneToOne = () => {
889
+ const w = Math.min(process.stdout.columns || 72, 72);
890
+ process.stdout.write('\n' + c.dim('─'.repeat(w)) + '\n');
891
+ process.stdout.write(
892
+ ` ${c.bold(c.cyan(serverAlias))} ${c.dim('/')} ${c.bold(username)} ` +
893
+ `${c.dim('→')} ${c.bold(c.green(recipient))} ` +
894
+ `${c.bgGreen(c.bold(' E2EE '))}\n`
895
+ );
896
+ process.stdout.write(
897
+ c.dim(' /chat <name|@group> switch · /who · /keys · /status · /groups · /inbox · /help\n')
898
+ );
899
+ process.stdout.write(c.dim('─'.repeat(w)) + '\n\n');
900
+ socket.emit('fetch_messages');
901
+ };
902
+ if (socket.connected) initOneToOne();
903
+ else socket.once('connect', initOneToOne);
904
+
905
+ socket.on('message_history', msgs => {
906
+ if (msgs.length) {
907
+ process.stdout.write(c.dim(` ── ${msgs.length} queued ──\n`));
908
+ for (const m of msgs) renderMsg(m, myKeys.preKeyPriv, username);
909
+ process.stdout.write(c.dim(' ─────────────\n\n'));
910
+ }
911
+ // Show cross-chat unread summary collected before we entered this chat
912
+ const dmTotal = Object.values(unreadBuf.dms).reduce((s, a) => s + a.length, 0);
913
+ const grpTotal = Object.values(unreadBuf.groups).reduce((s, a) => s + a.length, 0);
914
+ if (dmTotal || grpTotal) {
915
+ const parts = [];
916
+ if (dmTotal) parts.push(`${dmTotal} unread DM${dmTotal === 1 ? '' : 's'} from ${Object.keys(unreadBuf.dms).join(', ')}`);
917
+ if (grpTotal) parts.push(`${grpTotal} group msg${grpTotal === 1 ? '' : 's'} in ${Object.keys(unreadBuf.groups).map(g => '@' + g).join(', ')}`);
918
+ process.stdout.write(` ${c.bgYellow(c.bold(' UNREAD '))} ${c.dim(parts.join(' · '))} ${c.dim('· /inbox to read')}\n\n`);
919
+ }
920
+ getRL().setPrompt(` ${c.bold(c.cyan('you'))} ${c.dim('›')} `);
921
+ getRL().prompt();
922
+ });
923
+
924
+ // Inline handler: renders only messages from our current chat partner.
925
+ // Messages from others are already caught by the background handler above.
926
+ socket.on('receive_message', msg => {
927
+ if (msg.from !== recipient) return;
928
+ notify(msg.from, '…');
929
+ readline.clearLine(process.stdout, 0);
930
+ readline.cursorTo(process.stdout, 0);
931
+ renderMsg(msg, myKeys.preKeyPriv, username);
932
+ getRL().prompt(true);
933
+ });
934
+
935
+ getRL().removeAllListeners('line');
936
+ getRL().setPrompt(` ${c.bold(c.cyan('you'))} ${c.dim('›')} `);
937
+
938
+ getRL().on('line', async line => {
939
+ const text = line.trim();
940
+ if (!text) { getRL().prompt(); return; }
941
+
942
+ if (text === '/help') {
943
+ process.stdout.write(
944
+ `\n ${c.bold('Commands')}\n\n` +
945
+ ` ${c.bold(c.cyan('/chat <name>'))} switch to a DM with someone\n` +
946
+ ` ${c.bold(c.cyan('/chat @group'))} switch to a group chat\n` +
947
+ ` ${c.bold(c.cyan('/who'))} info about current recipient\n` +
948
+ ` ${c.bold(c.cyan('/status'))} check if recipient is online\n` +
949
+ ` ${c.bold(c.cyan('/keys'))} show key fingerprint\n` +
950
+ ` ${c.bold(c.cyan('/groups'))} list your groups\n` +
951
+ ` ${c.bold(c.cyan('/inbox'))} read unread from others\n` +
952
+ ` ${c.bold(c.cyan('/inbox <user>'))} read unread from one person\n` +
953
+ ` ${c.bold(c.cyan('/ghost'))} wipe all local data + exit\n` +
954
+ ` ${c.bold(c.cyan('/quit'))} exit\n\n`
955
+ );
956
+ getRL().prompt(); return;
957
+ }
958
+ if (text === '/who') {
959
+ process.stdout.write(` ${c.dim('Chatting with')} ${c.bold(c.green(recipient))} ${c.dim('on')} ${c.bold(serverAlias)}\n\n`);
960
+ getRL().prompt(); return;
961
+ }
962
+ if (text === '/status') {
963
+ try {
964
+ const { online } = await apiRequest(serverUrl, 'POST', '/online', { usernames: [recipient] }, token);
965
+ const dot = online.includes(recipient) ? c.brightGreen('● online') : c.dim('○ offline');
966
+ process.stdout.write(` ${c.bold(c.green(recipient))} ${dot}\n\n`);
967
+ } catch (e) { process.stdout.write(` ${c.brightRed('✖')} ${e.message}\n\n`); }
968
+ getRL().prompt(); return;
969
+ }
970
+ if (text === '/keys') {
971
+ const fp = crypto.createHash('sha256').update(theirKeys.identityKey || '').digest('hex').slice(0, 16);
972
+ process.stdout.write(` ${c.dim('Fingerprint')} ${c.yellow(fp.match(/.{4}/g).join(':'))}\n\n`);
973
+ getRL().prompt(); return;
974
+ }
975
+ if (text.startsWith('/chat ')) {
976
+ const target = text.slice(6).trim();
977
+ if (!target) { getRL().prompt(); return; }
978
+ socket.removeAllListeners('message_history');
979
+ socket.removeAllListeners('receive_message');
980
+ currentChat.type = null;
981
+ currentChat.with = null;
982
+ getRL().removeAllListeners('line');
983
+ const w = Math.min(process.stdout.columns || 72, 72);
984
+ process.stdout.write('\n' + c.dim('─'.repeat(w)) + '\n\n');
985
+ if (target.startsWith('@')) {
986
+ await runGroupChat(serverUrl, serverAlias, username, token, myKeys, socket, target.slice(1));
987
+ } else {
988
+ currentChat.type = 'dm';
989
+ currentChat.with = target;
990
+ const sw = spin(`Fetching ${target}'s keys…`);
991
+ try {
992
+ theirKeys = await apiRequest(serverUrl, 'GET', `/keys/${target}`, null, token);
993
+ keysCache.set(target, theirKeys.identityKey);
994
+ sw.ok(`Switched to ${c.bold(c.green(target))}`);
995
+ } catch (e) { sw.fail(`Cannot switch: ${e.message}`); currentChat.type = null; getRL().prompt(); return; }
996
+ // Re-init inline without recursing — rebuild handlers
997
+ const newRecipient = target;
998
+ const newW = Math.min(process.stdout.columns || 72, 72);
999
+ process.stdout.write(c.dim('─'.repeat(newW)) + '\n');
1000
+ process.stdout.write(
1001
+ ` ${c.bold(c.cyan(serverAlias))} ${c.dim('/')} ${c.bold(username)} ` +
1002
+ `${c.dim('→')} ${c.bold(c.green(newRecipient))} ` +
1003
+ `${c.bgGreen(c.bold(' E2EE '))}\n`
1004
+ );
1005
+ process.stdout.write(c.dim(' /chat <name|@group> switch · /who · /keys · /status · /groups · /inbox · /help\n'));
1006
+ process.stdout.write(c.dim('─'.repeat(newW)) + '\n\n');
1007
+ socket.emit('fetch_messages');
1008
+ socket.on('message_history', msgs2 => {
1009
+ if (msgs2.length) {
1010
+ process.stdout.write(c.dim(` ── ${msgs2.length} queued ──\n`));
1011
+ for (const m of msgs2) renderMsg(m, myKeys.preKeyPriv, username);
1012
+ process.stdout.write(c.dim(' ─────────────\n\n'));
1013
+ }
1014
+ getRL().setPrompt(` ${c.bold(c.cyan('you'))} ${c.dim('›')} `);
1015
+ getRL().prompt();
1016
+ });
1017
+ socket.on('receive_message', msg2 => {
1018
+ if (msg2.from !== newRecipient) return;
1019
+ notify(msg2.from, '…');
1020
+ readline.clearLine(process.stdout, 0);
1021
+ readline.cursorTo(process.stdout, 0);
1022
+ renderMsg(msg2, myKeys.preKeyPriv, username);
1023
+ getRL().prompt(true);
1024
+ });
1025
+ }
1026
+ return;
1027
+ }
1028
+ if (text === '/groups') {
1029
+ try {
1030
+ const { groups: myGroups } = await apiRequest(serverUrl, 'GET', '/groups', null, token);
1031
+ if (!myGroups.length) {
1032
+ process.stdout.write(c.dim(' No groups yet — chat with @groupname to create one.\n\n'));
1033
+ } else {
1034
+ for (const g of myGroups)
1035
+ process.stdout.write(` ${c.bold(c.cyan('@' + g.name))} ${c.dim('·')} ${g.memberCount} member${g.memberCount === 1 ? '' : 's'} ${g.owner === username ? c.dim('[owner]') : ''}\n`);
1036
+ process.stdout.write('\n');
1037
+ }
1038
+ } catch (e) { process.stdout.write(` ${c.brightRed('✖')} ${e.message}\n\n`); }
1039
+ getRL().prompt(); return;
1040
+ }
1041
+ if (text === '/inbox' || text.startsWith('/inbox ')) {
1042
+ const target = text.startsWith('/inbox ') ? text.slice(7).trim() : null;
1043
+ const dmKeys = Object.keys(unreadBuf.dms);
1044
+ const grpKeys = Object.keys(unreadBuf.groups);
1045
+ if (!dmKeys.length && !grpKeys.length) {
1046
+ process.stdout.write(c.dim(' No unread messages.\n\n'));
1047
+ getRL().prompt(); return;
1048
+ }
1049
+ if (target) {
1050
+ const msgs = unreadBuf.dms[target] || [];
1051
+ if (!msgs.length) { process.stdout.write(c.dim(` No unread from ${target}.\n\n`)); getRL().prompt(); return; }
1052
+ process.stdout.write(c.dim(` ── from ${target} ──\n`));
1053
+ for (const m of msgs) renderMsg(m, myKeys.preKeyPriv, username);
1054
+ process.stdout.write(c.dim(' ─────────────\n\n'));
1055
+ delete unreadBuf.dms[target];
1056
+ } else {
1057
+ for (const sender of dmKeys) {
1058
+ process.stdout.write(c.dim(` ── DM from ${sender} ──\n`));
1059
+ for (const m of unreadBuf.dms[sender]) renderMsg(m, myKeys.preKeyPriv, username);
1060
+ process.stdout.write(c.dim(' ─────────────\n\n'));
1061
+ }
1062
+ unreadBuf.dms = {};
1063
+ for (const gn of grpKeys) {
1064
+ const n = unreadBuf.groups[gn].length;
1065
+ process.stdout.write(c.dim(` ── @${gn}: ${n} message${n === 1 ? '' : 's'} — switch to @${gn} to read ──\n\n`));
1066
+ }
1067
+ }
1068
+ getRL().prompt(); return;
1069
+ }
1070
+ if (text === '/quit') {
1071
+ socket.disconnect(); getRL().close();
1072
+ process.stdout.write(c.dim('\n Disconnected.\n\n'));
1073
+ process.exit(0);
1074
+ }
1075
+ if (text === '/ghost') {
1076
+ socket.disconnect(); getRL().close();
1077
+ process.stdout.write(`\n ${c.bgRed(c.bold(' GHOST MODE '))} Wiping all local data…\n`);
1078
+ try { fs.rmSync(CONFIG_DIR, { recursive: true, force: true }); } catch {}
1079
+ process.stdout.write(c.dim(' Erased. Goodbye.\n\n'));
1080
+ process.exit(0);
1081
+ }
1082
+
1083
+ if (text.startsWith('/')) {
1084
+ process.stdout.write(c.dim(` Unknown command. Type /help for a list.\n\n`));
1085
+ getRL().prompt(); return;
1086
+ }
1087
+
1088
+ try {
1089
+ const eph = crypto.generateKeyPairSync('x25519', {
1090
+ publicKeyEncoding: { type: 'spki', format: 'der' },
1091
+ privateKeyEncoding: { type: 'pkcs8', format: 'der' },
1092
+ });
1093
+ const ratchetKey = eph.publicKey.toString('base64');
1094
+ const ratchetPriv = eph.privateKey.toString('base64');
1095
+ const aesKey = ecdhKey(ratchetPriv, theirKeys.preKey);
1096
+ const timestamp = Date.now();
1097
+ const aad = `zypher-dm:${username}:${recipient}`;
1098
+ const { ciphertext, nonce } = encryptMsg(text, aesKey, aad);
1099
+ const sigData = `${ciphertext}|${nonce}|${ratchetKey}|${recipient}|${timestamp}`;
1100
+ const signature = signMsg(sigData, myKeys.identityPrivKey);
1101
+ socket.emit('send_message', { to: recipient, ciphertext, nonce, ratchetKey, timestamp, signature });
1102
+ // Overwrite the raw input line with the formatted message display.
1103
+ // Use readline's own cursor APIs — raw ANSI codes conflict with
1104
+ // readline's internal terminal state on Windows.
1105
+ if (TTY) {
1106
+ readline.moveCursor(process.stdout, 0, -1); // move up one line
1107
+ readline.clearLine(process.stdout, 0); // erase it
1108
+ readline.cursorTo(process.stdout, 0); // go to column 0
1109
+ }
1110
+ process.stdout.write(` ${c.bold(c.cyan('you'))} ${c.gray(ts())} ${text}\n`);
1111
+ } catch (err) {
1112
+ process.stdout.write(` ${c.brightRed('✖')} Encrypt error: ${err.message}\n`);
1113
+ }
1114
+ getRL().prompt();
1115
+ });
1116
+ }
1117
+
1118
+ function renderMsg(msg, privB64, self) {
1119
+ const time = ts(msg.timestamp);
1120
+ const isSelf = msg.from === self;
1121
+ try {
1122
+ const key = ecdhKey(privB64, msg.ratchetKey);
1123
+ const aad = `zypher-dm:${msg.from}:${self}`;
1124
+ const text = decryptMsg(msg.ciphertext, msg.nonce, key, aad);
1125
+ // Verify Ed25519 signature if sender's identity key is cached
1126
+ let sigMark = '';
1127
+ const senderPub = keysCache.get(msg.from);
1128
+ if (senderPub && msg.signature) {
1129
+ const sigData = `${msg.ciphertext}|${msg.nonce}|${msg.ratchetKey}|${self}|${msg.timestamp}`;
1130
+ sigMark = verifyMsg(sigData, msg.signature, senderPub) ? c.dim(' ✓') : c.brightRed(' ⚠ TAMPERED');
1131
+ }
1132
+ const name = isSelf ? c.bold(c.cyan(msg.from)) : c.bold(c.green(msg.from));
1133
+ process.stdout.write(` ${name}${sigMark} ${c.gray(time)} ${text}\n`);
1134
+ } catch {
1135
+ process.stdout.write(` ${c.bold(c.green(msg.from))} ${c.gray(time)} ${c.dim('<unable to decrypt>')}\n`);
1136
+ }
1137
+ }
1138
+
1139
+ function renderGroupMsg(msg, groupKey, self) {
1140
+ const time = ts(msg.timestamp);
1141
+ try {
1142
+ const buf = Buffer.from(msg.ciphertext, 'base64');
1143
+ const nonce = Buffer.from(msg.nonce, 'base64');
1144
+ const tag = buf.slice(buf.length - 16);
1145
+ const enc = buf.slice(0, buf.length - 16);
1146
+ const aad = `zypher-group:${msg.from}:${msg.groupName}`;
1147
+ const d = crypto.createDecipheriv('aes-256-gcm', groupKey, nonce);
1148
+ d.setAAD(Buffer.from(aad));
1149
+ d.setAuthTag(tag);
1150
+ const text = Buffer.concat([d.update(enc), d.final()]).toString('utf8');
1151
+ // Verify Ed25519 signature if sender's identity key is cached
1152
+ let sigMark = '';
1153
+ const senderPub = keysCache.get(msg.from);
1154
+ if (senderPub && msg.signature) {
1155
+ const sigData = `${msg.ciphertext}|${msg.nonce}|${msg.groupName}|${msg.timestamp}`;
1156
+ sigMark = verifyMsg(sigData, msg.signature, senderPub) ? c.dim(' ✓') : c.brightRed(' ⚠ TAMPERED');
1157
+ }
1158
+ const name = msg.from === self ? c.bold(c.cyan(msg.from)) : c.bold(c.magenta(msg.from));
1159
+ process.stdout.write(` ${name}${sigMark} ${c.gray(time)} ${text}\n`);
1160
+ } catch {
1161
+ process.stdout.write(` ${c.bold(c.magenta(msg.from))} ${c.gray(time)} ${c.dim('<unable to decrypt>')}\n`);
1162
+ }
1163
+ }
1164
+
1165
+ async function runGroupChat(serverUrl, serverAlias, username, token, myKeys, socket, groupName) {
1166
+ // ── Resolve / create group ────────────────────────────────────────────────
1167
+ let groupInfo;
1168
+ {
1169
+ const s = spin(`Connecting to @${groupName}…`);
1170
+ try {
1171
+ groupInfo = await apiRequest(serverUrl, 'GET', `/groups/${encodeURIComponent(groupName)}`, null, token);
1172
+ s.ok(`Group @${groupName} · ${groupInfo.members.length} member${groupInfo.members.length === 1 ? '' : 's'}`);
1173
+ // Prefetch identity keys for all members so signatures can be verified
1174
+ await Promise.allSettled(groupInfo.members
1175
+ .filter(m => !keysCache.has(m))
1176
+ .map(async m => {
1177
+ try { const k = await apiRequest(serverUrl, 'GET', `/keys/${m}`, null, token); keysCache.set(m, k.identityKey); } catch {}
1178
+ })
1179
+ );
1180
+ } catch (err) {
1181
+ if (/not found|404/i.test(err.message)) {
1182
+ s.stop();
1183
+ process.stdout.write(`\n ${c.yellow('!')} Group @${c.bold(groupName)} does not exist.\n`);
1184
+ const ans = (await ask(' Create it? [y/N] : ')).trim().toLowerCase();
1185
+ if (ans !== 'y') { socket.disconnect(); return; }
1186
+ const cs = spin(`Creating @${groupName}…`);
1187
+ try {
1188
+ await apiRequest(serverUrl, 'POST', '/groups', { name: groupName }, token);
1189
+ cs.ok(`Created @${groupName}`);
1190
+ groupInfo = { name: groupName, owner: username, members: [username] };
1191
+ } catch (e) { cs.fail(`Failed: ${e.message}`); socket.disconnect(); return; }
1192
+ } else {
1193
+ s.fail(`Cannot access group: ${err.message}`);
1194
+ socket.disconnect(); return;
1195
+ }
1196
+ }
1197
+ }
1198
+
1199
+ // ── Resolve group key ─────────────────────────────────────────────────────
1200
+ let groupKey;
1201
+ {
1202
+ const s = spin('Loading group key…');
1203
+ try {
1204
+ const bundle = await apiRequest(serverUrl, 'GET', `/groups/${encodeURIComponent(groupName)}/key_bundle`, null, token);
1205
+ groupKey = decryptGroupKey(bundle, myKeys.preKeyPriv);
1206
+ s.ok('Group key ready');
1207
+ } catch (err) {
1208
+ if (/No key bundle/i.test(err.message)) {
1209
+ groupKey = crypto.randomBytes(32);
1210
+ s.update('Generating group key…');
1211
+ const myBundle = { username, ...encryptGroupKeyForUser(groupKey, myKeys.preKeyPub) };
1212
+ try {
1213
+ await apiRequest(serverUrl, 'POST', `/groups/${encodeURIComponent(groupName)}/key_bundle`, { bundles: [myBundle] }, token);
1214
+ s.ok('Group key generated');
1215
+ } catch (e) { s.fail(`Cannot store key: ${e.message}`); socket.disconnect(); return; }
1216
+ } else {
1217
+ s.fail(`Cannot load group key: ${err.message}`);
1218
+ socket.disconnect(); return;
1219
+ }
1220
+ }
1221
+ }
1222
+
1223
+ // ── Header + fetch offline queue ──────────────────────────────────────────
1224
+ currentChat.type = 'group';
1225
+ currentChat.with = groupName;
1226
+
1227
+ const initGroup = () => {
1228
+ socket.emit('fetch_group_messages', { groupName });
1229
+ const w = Math.min(process.stdout.columns || 72, 72);
1230
+ process.stdout.write('\n' + c.dim('─'.repeat(w)) + '\n');
1231
+ process.stdout.write(
1232
+ ` ${c.bold(c.cyan(serverAlias))} ${c.dim('/')} ${c.bold(username)} ` +
1233
+ `${c.dim('→')} ${c.bold(c.magenta('@' + groupName))} ` +
1234
+ `${c.bgGreen(c.bold(' E2EE GROUP '))} ${c.dim('·')} ${c.dim(groupInfo.members.length + ' member' + (groupInfo.members.length === 1 ? '' : 's'))}\n`
1235
+ );
1236
+ process.stdout.write(
1237
+ c.dim(' /chat <name|@group> switch · /members · /status · /invite · /inbox · /help\n')
1238
+ );
1239
+ process.stdout.write(c.dim('─'.repeat(w)) + '\n\n');
1240
+ };
1241
+ if (socket.connected) initGroup();
1242
+ else socket.once('connect', initGroup);
1243
+
1244
+ socket.on('group_message_history', ({ groupName: gn, msgs }) => {
1245
+ if (gn !== groupName) return;
1246
+ if (msgs.length) {
1247
+ process.stdout.write(c.dim(` ── ${msgs.length} queued ──\n`));
1248
+ for (const m of msgs) renderGroupMsg(m, groupKey, username);
1249
+ process.stdout.write(c.dim(' ─────────────\n\n'));
1250
+ }
1251
+ // Show cross-chat unread summary collected before we entered this group
1252
+ const dmTotal = Object.values(unreadBuf.dms).reduce((s, a) => s + a.length, 0);
1253
+ const grpTotal = Object.values(unreadBuf.groups).filter(k => k !== groupName).reduce((s, a) => s + a.length, 0);
1254
+ if (dmTotal || grpTotal) {
1255
+ const parts = [];
1256
+ if (dmTotal) parts.push(`${dmTotal} unread DM${dmTotal === 1 ? '' : 's'} from ${Object.keys(unreadBuf.dms).join(', ')}`);
1257
+ if (grpTotal) parts.push(`${grpTotal} group msg${grpTotal === 1 ? '' : 's'} in ${Object.keys(unreadBuf.groups).filter(g => g !== groupName).map(g => '@' + g).join(', ')}`);
1258
+ process.stdout.write(` ${c.bgYellow(c.bold(' UNREAD '))} ${c.dim(parts.join(' · '))} ${c.dim('· /inbox to read')}\n\n`);
1259
+ }
1260
+ getRL().setPrompt(` ${c.bold(c.magenta('@' + groupName))} ${c.dim('›')} `);
1261
+ getRL().prompt();
1262
+ });
1263
+
1264
+ socket.on('receive_group_message', msg => {
1265
+ if (msg.groupName !== groupName) return;
1266
+ if (msg.from !== username) notify(msg.from, `[group @${groupName}]`);
1267
+ readline.clearLine(process.stdout, 0);
1268
+ readline.cursorTo(process.stdout, 0);
1269
+ renderGroupMsg(msg, groupKey, username);
1270
+ getRL().prompt(true);
1271
+ });
1272
+
1273
+ getRL().removeAllListeners('line');
1274
+ getRL().setPrompt(` ${c.bold(c.magenta('@' + groupName))} ${c.dim('›')} `);
1275
+
1276
+ getRL().on('line', async line => {
1277
+ const text = line.trim();
1278
+ if (!text) { getRL().prompt(); return; }
1279
+
1280
+ if (text === '/help') {
1281
+ process.stdout.write(
1282
+ `\n ${c.bold('Commands')}\n\n` +
1283
+ ` ${c.bold(c.magenta('/chat <name>'))} switch to a DM\n` +
1284
+ ` ${c.bold(c.magenta('/chat @group'))} switch to another group\n` +
1285
+ ` ${c.bold(c.magenta('/members'))} list members + online status\n` +
1286
+ ` ${c.bold(c.magenta('/status'))} show group online count\n` +
1287
+ ` ${c.bold(c.magenta('/invite <user>'))} invite someone\n` +
1288
+ ` ${c.bold(c.magenta('/inbox'))} read unread from other chats\n` +
1289
+ ` ${c.bold(c.magenta('/ghost'))} wipe all local data + exit\n` +
1290
+ ` ${c.bold(c.magenta('/quit'))} exit\n\n`
1291
+ );
1292
+ getRL().prompt(); return;
1293
+ }
1294
+
1295
+ if (text === '/members') {
1296
+ try {
1297
+ const info = await apiRequest(serverUrl, 'GET', `/groups/${encodeURIComponent(groupName)}`, null, token);
1298
+ const { online } = await apiRequest(serverUrl, 'POST', '/online', { usernames: info.members }, token);
1299
+ const onlineSet = new Set(online);
1300
+ const w = Math.min(process.stdout.columns || 72, 72);
1301
+ process.stdout.write(c.dim(` ── @${groupName} members ──\n`));
1302
+ for (const m of info.members) {
1303
+ const dot = onlineSet.has(m) ? c.brightGreen('●') : c.dim('○');
1304
+ const name = m === username ? c.bold(c.cyan(m + ' (you)')) : c.bold(c.green(m));
1305
+ const own = m === info.owner ? c.dim(' [owner]') : '';
1306
+ process.stdout.write(` ${dot} ${name}${own}\n`);
1307
+ }
1308
+ process.stdout.write('\n');
1309
+ } catch (e) { process.stdout.write(` ${c.brightRed('✖')} ${e.message}\n\n`); }
1310
+ getRL().prompt(); return;
1311
+ }
1312
+
1313
+ if (text === '/status') {
1314
+ try {
1315
+ const info = await apiRequest(serverUrl, 'GET', `/groups/${encodeURIComponent(groupName)}`, null, token);
1316
+ const { online } = await apiRequest(serverUrl, 'POST', '/online', { usernames: info.members }, token);
1317
+ const onlineSet = new Set(online);
1318
+ process.stdout.write(` ${c.bold(c.magenta('@' + groupName))} members:\n`);
1319
+ for (const m of info.members) {
1320
+ const dot = onlineSet.has(m) ? c.brightGreen('●') : c.dim('○');
1321
+ const tag = m === info.owner ? c.dim(' [owner]') : '';
1322
+ const you = m === username ? c.dim(' (you)') : '';
1323
+ process.stdout.write(` ${dot} ${c.bold(m)}${tag}${you}\n`);
1324
+ }
1325
+ process.stdout.write('\n');
1326
+ } catch (e) { process.stdout.write(` ${c.brightRed('✖')} ${e.message}\n\n`); }
1327
+ getRL().prompt(); return;
1328
+ }
1329
+
1330
+ if (text.startsWith('/invite ') || text === '/invite') {
1331
+ const invitee = text.slice(8).trim();
1332
+ if (!invitee) { process.stdout.write(c.dim(' Usage: /invite <username>\n\n')); getRL().prompt(); return; }
1333
+ const si = spin(`Inviting ${c.bold(invitee)}…`);
1334
+ try {
1335
+ const theirKeys = await apiRequest(serverUrl, 'GET', `/keys/${invitee}`, null, token);
1336
+ await apiRequest(serverUrl, 'POST', `/groups/${encodeURIComponent(groupName)}/invite`, { username: invitee }, token);
1337
+ const bundle = { username: invitee, ...encryptGroupKeyForUser(groupKey, theirKeys.preKey) };
1338
+ await apiRequest(serverUrl, 'POST', `/groups/${encodeURIComponent(groupName)}/key_bundle`, { bundles: [bundle] }, token);
1339
+ si.ok(`${c.bold(invitee)} invited to @${groupName}`);
1340
+ } catch (e) { si.fail(`Invite failed: ${e.message}`); }
1341
+ getRL().prompt(); return;
1342
+ }
1343
+
1344
+ if (text.startsWith('/chat ')) {
1345
+ const target = text.slice(6).trim();
1346
+ if (!target) { getRL().prompt(); return; }
1347
+ socket.removeAllListeners('group_message_history');
1348
+ socket.removeAllListeners('receive_group_message');
1349
+ currentChat.type = null;
1350
+ currentChat.with = null;
1351
+ getRL().removeAllListeners('line');
1352
+ const w = Math.min(process.stdout.columns || 72, 72);
1353
+ process.stdout.write('\n' + c.dim('─'.repeat(w)) + '\n\n');
1354
+ if (target.startsWith('@')) {
1355
+ await runGroupChat(serverUrl, serverAlias, username, token, myKeys, socket, target.slice(1));
1356
+ } else {
1357
+ // Switch to a 1:1, re-enter runGroupChat's parent (runChat) is not possible,
1358
+ // so inline-construct a minimal 1:1 session.
1359
+ currentChat.type = 'dm';
1360
+ currentChat.with = target;
1361
+ const sw = spin(`Fetching ${target}'s keys…`);
1362
+ let targetKeys;
1363
+ try {
1364
+ targetKeys = await apiRequest(serverUrl, 'GET', `/keys/${target}`, null, token);
1365
+ keysCache.set(target, targetKeys.identityKey);
1366
+ sw.ok(`Switched to ${c.bold(c.green(target))}`);
1367
+ } catch (e) { sw.fail(`Cannot switch: ${e.message}`); currentChat.type = null; getRL().prompt(); return; }
1368
+ const newW = Math.min(process.stdout.columns || 72, 72);
1369
+ process.stdout.write(c.dim('─'.repeat(newW)) + '\n');
1370
+ process.stdout.write(
1371
+ ` ${c.bold(c.cyan(serverAlias))} ${c.dim('/')} ${c.bold(username)} ` +
1372
+ `${c.dim('→')} ${c.bold(c.green(target))} ` +
1373
+ `${c.bgGreen(c.bold(' E2EE '))}\n`
1374
+ );
1375
+ process.stdout.write(c.dim(' /chat <name|@group> switch · /who · /keys · /status · /groups · /inbox · /help\n'));
1376
+ process.stdout.write(c.dim('─'.repeat(newW)) + '\n\n');
1377
+ socket.emit('fetch_messages');
1378
+ socket.on('message_history', msgs2 => {
1379
+ if (msgs2.length) {
1380
+ process.stdout.write(c.dim(` ── ${msgs2.length} queued ──\n`));
1381
+ for (const m of msgs2) renderMsg(m, myKeys.preKeyPriv, username);
1382
+ process.stdout.write(c.dim(' ─────────────\n\n'));
1383
+ }
1384
+ getRL().setPrompt(` ${c.bold(c.cyan('you'))} ${c.dim('›')} `);
1385
+ getRL().prompt();
1386
+ });
1387
+ socket.on('receive_message', msg2 => {
1388
+ if (msg2.from !== target) return;
1389
+ notify(msg2.from, '…');
1390
+ readline.clearLine(process.stdout, 0);
1391
+ readline.cursorTo(process.stdout, 0);
1392
+ renderMsg(msg2, myKeys.preKeyPriv, username);
1393
+ getRL().prompt(true);
1394
+ });
1395
+ getRL().removeAllListeners('line');
1396
+ getRL().setPrompt(` ${c.bold(c.cyan('you'))} ${c.dim('›')} `);
1397
+ getRL().on('line', async line2 => {
1398
+ const t2 = line2.trim();
1399
+ if (!t2) { getRL().prompt(); return; }
1400
+ if (t2 === '/quit') { socket.disconnect(); getRL().close(); process.stdout.write(c.dim('\n Disconnected.\n\n')); process.exit(0); }
1401
+ if (t2 === '/ghost') { socket.disconnect(); getRL().close(); try { fs.rmSync(CONFIG_DIR, { recursive: true, force: true }); } catch {} process.stdout.write(c.dim(' Erased.\n\n')); process.exit(0); }
1402
+ if (t2 === '/who') { process.stdout.write(` ${c.dim('Chatting with')} ${c.bold(c.green(target))}\n\n`); getRL().prompt(); return; }
1403
+ if (t2 === '/status') {
1404
+ try { const { online } = await apiRequest(serverUrl, 'POST', '/online', { usernames: [target] }, token); process.stdout.write(` ${c.bold(c.green(target))} ${online.includes(target) ? c.brightGreen('● online') : c.dim('○ offline')}\n\n`); } catch (e) { process.stdout.write(` ${c.brightRed('✖')} ${e.message}\n\n`); }
1405
+ getRL().prompt(); return;
1406
+ }
1407
+ if (t2.startsWith('/chat ')) {
1408
+ const t3 = t2.slice(6).trim();
1409
+ if (!t3) { getRL().prompt(); return; }
1410
+ socket.removeAllListeners('message_history'); socket.removeAllListeners('receive_message');
1411
+ currentChat.type = null; currentChat.with = null; getRL().removeAllListeners('line');
1412
+ process.stdout.write('\n' + c.dim('─'.repeat(Math.min(process.stdout.columns||72,72))) + '\n\n');
1413
+ if (t3.startsWith('@')) { await runGroupChat(serverUrl, serverAlias, username, token, myKeys, socket, t3.slice(1)); }
1414
+ else { currentChat.type='dm'; currentChat.with=t3; /* minimal re-init not repeated here */ getRL().prompt(); }
1415
+ return;
1416
+ }
1417
+ try {
1418
+ const eph2 = crypto.generateKeyPairSync('x25519', { publicKeyEncoding: { type:'spki', format:'der' }, privateKeyEncoding: { type:'pkcs8', format:'der' } });
1419
+ const rk2 = eph2.publicKey.toString('base64'), rp2 = eph2.privateKey.toString('base64');
1420
+ const ak2 = ecdhKey(rp2, targetKeys.preKey);
1421
+ const ts2 = Date.now();
1422
+ const aad2 = `zypher-dm:${username}:${target}`;
1423
+ const { ciphertext: ct2, nonce: n2 } = encryptMsg(t2, ak2, aad2);
1424
+ const sig2 = signMsg(`${ct2}|${n2}|${rk2}|${target}|${ts2}`, myKeys.identityPrivKey);
1425
+ socket.emit('send_message', { to: target, ciphertext: ct2, nonce: n2, ratchetKey: rk2, timestamp: ts2, signature: sig2 });
1426
+ if (TTY) { readline.moveCursor(process.stdout,0,-1); readline.clearLine(process.stdout,0); readline.cursorTo(process.stdout,0); }
1427
+ process.stdout.write(` ${c.bold(c.cyan('you'))} ${c.gray(ts())} ${t2}\n`);
1428
+ } catch (err) { process.stdout.write(` ${c.brightRed('✖')} ${err.message}\n`); }
1429
+ getRL().prompt();
1430
+ });
1431
+ }
1432
+ return;
1433
+ }
1434
+
1435
+ if (text === '/inbox') {
1436
+ const dmKeys = Object.keys(unreadBuf.dms);
1437
+ const grpKeys = Object.keys(unreadBuf.groups).filter(g => g !== groupName);
1438
+ if (!dmKeys.length && !grpKeys.length) {
1439
+ process.stdout.write(c.dim(' No unread messages.\n\n'));
1440
+ getRL().prompt(); return;
1441
+ }
1442
+ for (const sender of dmKeys) {
1443
+ process.stdout.write(c.dim(` ── DM from ${sender} ──\n`));
1444
+ for (const m of unreadBuf.dms[sender]) renderMsg(m, myKeys.preKeyPriv, username);
1445
+ process.stdout.write(c.dim(' ─────────────\n\n'));
1446
+ }
1447
+ unreadBuf.dms = {};
1448
+ for (const gn of grpKeys) {
1449
+ const n = unreadBuf.groups[gn].length;
1450
+ process.stdout.write(c.dim(` ── @${gn}: ${n} message${n === 1 ? '' : 's'} — switch to @${gn} to read ──\n\n`));
1451
+ }
1452
+ getRL().prompt(); return;
1453
+ }
1454
+ if (text === '/quit') {
1455
+ socket.disconnect(); getRL().close();
1456
+ process.stdout.write(c.dim('\n Disconnected.\n\n'));
1457
+ process.exit(0);
1458
+ }
1459
+ if (text === '/ghost') {
1460
+ socket.disconnect(); getRL().close();
1461
+ process.stdout.write(`\n ${c.bgRed(c.bold(' GHOST MODE '))} Wiping all local data…\n`);
1462
+ try { fs.rmSync(CONFIG_DIR, { recursive: true, force: true }); } catch {}
1463
+ process.stdout.write(c.dim(' Erased. Goodbye.\n\n'));
1464
+ process.exit(0);
1465
+ }
1466
+
1467
+ if (text.startsWith('/')) {
1468
+ process.stdout.write(c.dim(` Unknown command. Type /help for a list.\n\n`));
1469
+ getRL().prompt(); return;
1470
+ }
1471
+
1472
+ // Encrypt with the shared group key (AES-256-GCM, unique nonce per message)
1473
+ try {
1474
+ const nonce = crypto.randomBytes(12);
1475
+ const timestamp = Date.now();
1476
+ const aad = `zypher-group:${username}:${groupName}`;
1477
+ const cipher = crypto.createCipheriv('aes-256-gcm', groupKey, nonce);
1478
+ cipher.setAAD(Buffer.from(aad));
1479
+ const enc = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
1480
+ const tag = cipher.getAuthTag();
1481
+ const ciphertext = Buffer.concat([enc, tag]).toString('base64');
1482
+ const nonceB64 = nonce.toString('base64');
1483
+ const sigData = `${ciphertext}|${nonceB64}|${groupName}|${timestamp}`;
1484
+ const signature = signMsg(sigData, myKeys.identityPrivKey);
1485
+ socket.emit('send_group_message', { groupName, ciphertext, nonce: nonceB64, timestamp, signature });
1486
+ if (TTY) {
1487
+ readline.moveCursor(process.stdout, 0, -1);
1488
+ readline.clearLine(process.stdout, 0);
1489
+ readline.cursorTo(process.stdout, 0);
1490
+ }
1491
+ process.stdout.write(` ${c.bold(c.cyan('you'))} ${c.gray(ts())} ${text}\n`);
1492
+ } catch (err) {
1493
+ process.stdout.write(` ${c.brightRed('✖')} Encrypt error: ${err.message}\n`);
1494
+ }
1495
+ getRL().prompt();
1496
+ });
1497
+ }
1498
+
1499
+ function ts(ms) {
1500
+ return new Date(ms || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1501
+ }
1502
+
1503
+ // ── zypher settings ───────────────────────────────────────────────────────────
1504
+ function getDaemonPid() {
1505
+ try {
1506
+ const pid = parseInt(fs.readFileSync(SERVER_PID, 'utf8').trim(), 10);
1507
+ process.kill(pid, 0); return pid;
1508
+ } catch { return null; }
1509
+ }
1510
+
1511
+ async function cmdSettings() {
1512
+ while (true) {
1513
+ printBanner();
1514
+ const cfg = loadConfig();
1515
+ const serverList = Object.keys(cfg.servers || {});
1516
+ const pid = getDaemonPid();
1517
+
1518
+ process.stdout.write(rule('settings') + '\n\n');
1519
+
1520
+ // Servers table
1521
+ process.stdout.write(` ${c.bold('Servers')}\n\n`);
1522
+ if (!serverList.length) {
1523
+ process.stdout.write(c.dim(' none\n'));
1524
+ } else {
1525
+ for (const alias of serverList) {
1526
+ const srv = cfg.servers[alias];
1527
+ const count = Object.keys(cfg.accounts?.[alias] || {}).length;
1528
+ process.stdout.write(
1529
+ ` ${c.bold(c.cyan(alias.padEnd(16)))} ${c.gray(srv.url.padEnd(32))} ` +
1530
+ `${c.dim(count + ' acct' + (count !== 1 ? 's' : ''))}\n`
1531
+ );
1532
+ }
1533
+ }
1534
+ process.stdout.write('\n');
1535
+ process.stdout.write(` ${c.dim('Local daemon')} ${pid ? badge('running') + c.dim(` PID ${pid}`) : badge('stopped')}\n`);
1536
+ process.stdout.write('\n' + rule('options') + '\n\n');
1537
+
1538
+ const opts = [
1539
+ ['1', 'Add / connect to new server'],
1540
+ ['2', 'Rename server'],
1541
+ ['3', 'Remove server'],
1542
+ ['4', 'List accounts'],
1543
+ ['5', 'Remove account'],
1544
+ ['6', 'Daemon status'],
1545
+ ['7', c.red('GHOST — erase ALL local data')],
1546
+ ['8', 'Back / Quit'],
1547
+ ];
1548
+ for (const [k, label] of opts)
1549
+ process.stdout.write(` ${c.bold(c.cyan(k))} ${label}\n`);
1550
+ process.stdout.write('\n');
1551
+
1552
+ const choice = (await ask(` ${c.dim('›')} `)).trim();
1553
+ process.stdout.write('\n');
1554
+
1555
+ if (choice === '1') {
1556
+ getRL().close(); rl = null;
1557
+ await cmdNew();
1558
+ rl = null;
1559
+
1560
+ } else if (choice === '2') {
1561
+ const old = (await ask(` ${c.bold('Current alias')} : `)).trim();
1562
+ if (!cfg.servers[old]) { process.stdout.write(' Not found.\n\n'); continue; }
1563
+ const nxt = (await ask(` ${c.bold('New alias')} : `)).trim();
1564
+ if (!nxt) { process.stdout.write(' Cannot be empty.\n\n'); continue; }
1565
+ cfg.servers[nxt] = { ...cfg.servers[old], alias: nxt };
1566
+ delete cfg.servers[old];
1567
+ if (cfg.accounts[old]) { cfg.accounts[nxt] = cfg.accounts[old]; delete cfg.accounts[old]; }
1568
+ saveConfig(cfg);
1569
+ process.stdout.write(` ${c.brightGreen('✔')} Renamed "${old}" → "${nxt}"\n\n`);
1570
+
1571
+ } else if (choice === '3') {
1572
+ const alias = (await ask(` ${c.bold('Alias to remove')} : `)).trim();
1573
+ if (!cfg.servers[alias]) { process.stdout.write(' Not found.\n\n'); continue; }
1574
+ delete cfg.servers[alias]; delete cfg.accounts[alias];
1575
+ saveConfig(cfg);
1576
+ process.stdout.write(` ${c.brightGreen('✔')} Removed "${alias}"\n\n`);
1577
+
1578
+ } else if (choice === '4') {
1579
+ const entries = [];
1580
+ for (const srv of Object.keys(cfg.accounts || {}))
1581
+ for (const u of Object.keys(cfg.accounts[srv]))
1582
+ entries.push(` ${c.bold(c.cyan(srv))} ${c.dim('/')} ${u}`);
1583
+ process.stdout.write(entries.length ? entries.join('\n') + '\n\n' : c.dim(' No accounts stored.\n\n'));
1584
+
1585
+ } else if (choice === '5') {
1586
+ const srv = (await ask(` ${c.bold('Server alias')} : `)).trim();
1587
+ const usr = (await ask(` ${c.bold('Username')} : `)).trim();
1588
+ if (cfg.accounts?.[srv]?.[usr]) {
1589
+ delete cfg.accounts[srv][usr]; saveConfig(cfg);
1590
+ process.stdout.write(` ${c.brightGreen('✔')} Removed "${usr}" from "${srv}"\n\n`);
1591
+ } else {
1592
+ process.stdout.write(' Not found.\n\n');
1593
+ }
1594
+
1595
+ } else if (choice === '6') {
1596
+ cmdServerStatus();
1597
+
1598
+ } else if (choice === '7') {
1599
+ process.stdout.write(` ${c.bgRed(c.bold(' !! GHOST MODE !! '))} This erases everything.\n\n`);
1600
+ const confirm = (await ask(` Type ${c.bold('GHOST')} to confirm: `)).trim();
1601
+ if (confirm === 'GHOST') {
1602
+ try { fs.rmSync(CONFIG_DIR, { recursive: true, force: true }); } catch {}
1603
+ process.stdout.write(`\n ${c.brightGreen('✔')} All local data erased.\n\n`);
1604
+ getRL().close(); process.exit(0);
1605
+ } else {
1606
+ process.stdout.write(' Cancelled.\n\n');
1607
+ }
1608
+
1609
+ } else {
1610
+ getRL().close(); break;
1611
+ }
1612
+ }
1613
+ }
1614
+
1615
+ // ── Entry point ───────────────────────────────────────────────────────────────
1616
+ async function main() {
1617
+ const [,, cmd, sub] = process.argv;
1618
+ try {
1619
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') { printHelp(); process.exit(0); }
1620
+
1621
+ if (cmd === 'server') {
1622
+ if (sub === 'quickstart') { await cmdServerQuickstart(); return; }
1623
+ if (sub === 'stop') { cmdServerStop(); return; }
1624
+ if (sub === 'status') { cmdServerStatus(); return; }
1625
+ process.stdout.write(`\n ${c.brightRed('✖')} Unknown server subcommand "${sub}".\n\n`);
1626
+ printHelp(); process.exit(1);
1627
+ }
1628
+
1629
+ if (cmd === 'new') { await cmdNew(); return; }
1630
+ if (cmd === 'settings') { await cmdSettings(); return; }
1631
+
1632
+ await cmdConnect(cmd, sub);
1633
+ } catch (err) {
1634
+ process.stdout.write(`\n ${c.brightRed('✖')} ${err.message || err}\n\n`);
1635
+ if (rl) { try { rl.close(); } catch {} }
1636
+ process.exit(1);
1637
+ }
1638
+ }
1639
+
1640
+ main();