ciphermesh 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/README.pt-BR.md +253 -0
- package/bin/ciphermesh.js +34 -0
- package/docs/ARCHITECTURE.md +1188 -0
- package/docs/SETUP.md +305 -0
- package/docs/demo.svg +46 -0
- package/package.json +87 -0
- package/src/client/ChatController.js +2476 -0
- package/src/client/Connection.js +129 -0
- package/src/client/FileTransfer.js +488 -0
- package/src/client/ImagePreview.js +88 -0
- package/src/client/UI.js +1830 -0
- package/src/client/index.js +231 -0
- package/src/crypto/CertPinStore.js +79 -0
- package/src/crypto/DeniableEncrypt.js +53 -0
- package/src/crypto/DoubleRatchet.js +574 -0
- package/src/crypto/Handshake.js +219 -0
- package/src/crypto/HistoryStore.js +241 -0
- package/src/crypto/IdentityBackup.js +70 -0
- package/src/crypto/KeyManager.js +134 -0
- package/src/crypto/MessageCrypto.js +181 -0
- package/src/crypto/NonceManager.js +72 -0
- package/src/crypto/SealedSender.js +58 -0
- package/src/crypto/SenderKey.js +204 -0
- package/src/crypto/StateManager.js +138 -0
- package/src/crypto/TrustStore.js +216 -0
- package/src/p2p/Discovery.js +80 -0
- package/src/p2p/P2PChatController.js +1856 -0
- package/src/p2p/PeerConnectionManager.js +252 -0
- package/src/p2p/PeerServer.js +68 -0
- package/src/p2p/index.js +219 -0
- package/src/protocol/messages.js +138 -0
- package/src/protocol/validators.js +175 -0
- package/src/server/CertManager.js +173 -0
- package/src/server/MessageRouter.js +80 -0
- package/src/server/OfflineQueue.js +124 -0
- package/src/server/SessionManager.js +296 -0
- package/src/server/WebSocketServer.js +632 -0
- package/src/server/index.js +89 -0
- package/src/shared/AuditLog.js +91 -0
- package/src/shared/PluginManager.js +83 -0
- package/src/shared/banner.js +271 -0
- package/src/shared/commandSuggest.js +59 -0
- package/src/shared/config.js +90 -0
- package/src/shared/constants.js +126 -0
- package/src/shared/coverTraffic.js +34 -0
- package/src/shared/dnd.js +60 -0
- package/src/shared/emoji.js +17 -0
- package/src/shared/fuzzy.js +40 -0
- package/src/shared/invite.js +61 -0
- package/src/shared/keyArt.js +66 -0
- package/src/shared/logger.js +38 -0
- package/src/shared/panic.js +38 -0
- package/src/shared/prompt.js +31 -0
- package/src/shared/terminalGraphics.js +72 -0
- package/src/shared/themes.js +36 -0
- package/src/shared/voiceNote.js +128 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
statSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
|
|
13
|
+
export const AuditEvent = {
|
|
14
|
+
TRUST_NEW_PEER: 'TRUST_NEW_PEER',
|
|
15
|
+
TRUST_MISMATCH: 'TRUST_MISMATCH',
|
|
16
|
+
TRUST_VERIFIED_MISMATCH: 'TRUST_VERIFIED_MISMATCH',
|
|
17
|
+
KEY_ROTATION_OWN: 'KEY_ROTATION_OWN',
|
|
18
|
+
KEY_ROTATION_PEER: 'KEY_ROTATION_PEER',
|
|
19
|
+
SAS_VERIFY: 'SAS_VERIFY',
|
|
20
|
+
SAS_CONFIRM: 'SAS_CONFIRM',
|
|
21
|
+
DECRYPT_FAILURE: 'DECRYPT_FAILURE',
|
|
22
|
+
NONCE_REPLAY: 'NONCE_REPLAY',
|
|
23
|
+
PEER_CONNECTED: 'PEER_CONNECTED',
|
|
24
|
+
PEER_DISCONNECTED: 'PEER_DISCONNECTED',
|
|
25
|
+
ROOM_CHANGED: 'ROOM_CHANGED',
|
|
26
|
+
ADMIN_KICK: 'ADMIN_KICK',
|
|
27
|
+
ADMIN_MUTE: 'ADMIN_MUTE',
|
|
28
|
+
ADMIN_BAN: 'ADMIN_BAN',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export class AuditLog {
|
|
32
|
+
#filePath;
|
|
33
|
+
|
|
34
|
+
constructor() {
|
|
35
|
+
const dir = join(homedir(), '.ciphermesh');
|
|
36
|
+
if (!existsSync(dir)) {
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
this.#filePath = join(dir, 'audit.log');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Securely delete the audit log from disk (panic / duress).
|
|
43
|
+
wipe() {
|
|
44
|
+
try {
|
|
45
|
+
if (existsSync(this.#filePath)) {
|
|
46
|
+
writeFileSync(this.#filePath, Buffer.alloc(Math.max(256, statSync(this.#filePath).size)));
|
|
47
|
+
unlinkSync(this.#filePath);
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
/* best effort */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
log(eventType, details = {}) {
|
|
55
|
+
const entry = {
|
|
56
|
+
ts: new Date().toISOString(),
|
|
57
|
+
event: eventType,
|
|
58
|
+
...details,
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
appendFileSync(this.#filePath, JSON.stringify(entry) + '\n', {
|
|
62
|
+
encoding: 'utf-8',
|
|
63
|
+
mode: 0o600,
|
|
64
|
+
});
|
|
65
|
+
} catch {
|
|
66
|
+
// Silently fail — audit log should never break the app
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
readLast(n = 20) {
|
|
71
|
+
try {
|
|
72
|
+
if (!existsSync(this.#filePath)) {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const content = readFileSync(this.#filePath, 'utf-8').trim();
|
|
76
|
+
if (!content) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
const lines = content.split('\n');
|
|
80
|
+
return lines.slice(-n).map((line) => {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(line);
|
|
83
|
+
} catch {
|
|
84
|
+
return { raw: line };
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const PLUGIN_DIR = join(homedir(), '.ciphermesh', 'plugins');
|
|
8
|
+
|
|
9
|
+
export class PluginManager {
|
|
10
|
+
#plugins; // Map<name, module>
|
|
11
|
+
#commands; // Map<cmdName, handler>
|
|
12
|
+
|
|
13
|
+
constructor() {
|
|
14
|
+
this.#plugins = new Map();
|
|
15
|
+
this.#commands = new Map();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async loadAll() {
|
|
19
|
+
if (!existsSync(PLUGIN_DIR)) {
|
|
20
|
+
mkdirSync(PLUGIN_DIR, { recursive: true });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let files;
|
|
25
|
+
try {
|
|
26
|
+
files = await readdir(PLUGIN_DIR);
|
|
27
|
+
} catch {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const jsFiles = files.filter((f) => f.endsWith('.js'));
|
|
32
|
+
|
|
33
|
+
for (const file of jsFiles) {
|
|
34
|
+
try {
|
|
35
|
+
const filePath = join(PLUGIN_DIR, file);
|
|
36
|
+
const fileUrl = pathToFileURL(filePath).href;
|
|
37
|
+
const mod = await import(fileUrl);
|
|
38
|
+
const plugin = mod.default || mod;
|
|
39
|
+
|
|
40
|
+
if (!plugin.name || !plugin.commands) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.#plugins.set(plugin.name, plugin);
|
|
45
|
+
|
|
46
|
+
for (const [cmdName, handler] of Object.entries(plugin.commands)) {
|
|
47
|
+
const normalized = cmdName.startsWith('/')
|
|
48
|
+
? cmdName.toLowerCase()
|
|
49
|
+
: `/${cmdName.toLowerCase()}`;
|
|
50
|
+
this.#commands.set(normalized, { handler, pluginName: plugin.name });
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// Skip broken plugins silently
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
getCommandNames() {
|
|
59
|
+
return [...this.#commands.keys()];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getPluginNames() {
|
|
63
|
+
return [...this.#plugins.keys()];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get pluginCount() {
|
|
67
|
+
return this.#plugins.size;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
handleCommand(cmd, args) {
|
|
71
|
+
const entry = this.#commands.get(cmd.toLowerCase());
|
|
72
|
+
if (!entry) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const result = entry.handler(args);
|
|
78
|
+
return result || null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import figlet from 'figlet';
|
|
2
|
+
import gradient from 'gradient-string';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import boxen from 'boxen';
|
|
5
|
+
|
|
6
|
+
const neon = gradient(['#00ff9f', '#00b8ff', '#7b2dff', '#ff2dff']);
|
|
7
|
+
const cyber = gradient(['#f72585', '#7209b7', '#3a0ca3', '#4361ee', '#4cc9f0']);
|
|
8
|
+
const mint = gradient(['#00ff9f', '#00b8ff']);
|
|
9
|
+
|
|
10
|
+
function logo() {
|
|
11
|
+
return neon(figlet.textSync('CipherMesh', { font: 'ANSI Shadow' }));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function serverBanner(port, network, tls = false) {
|
|
15
|
+
const { ips, inDocker } = network;
|
|
16
|
+
const proto = tls ? 'wss' : 'ws';
|
|
17
|
+
|
|
18
|
+
console.clear();
|
|
19
|
+
console.log(logo());
|
|
20
|
+
console.log(cyber(' ░▒▓ End-to-End Encrypted Relay ▓▒░'));
|
|
21
|
+
console.log();
|
|
22
|
+
|
|
23
|
+
const lines = [];
|
|
24
|
+
lines.push(chalk.hex('#4cc9f0')(' Port ') + chalk.bold.white(port));
|
|
25
|
+
|
|
26
|
+
if (ips.length > 0) {
|
|
27
|
+
for (const { name, address, tailscale } of ips) {
|
|
28
|
+
const label = tailscale ? 'Internet' : name;
|
|
29
|
+
lines.push(
|
|
30
|
+
chalk.hex('#4cc9f0')(` ${label.padEnd(8)} `) +
|
|
31
|
+
chalk.bold.white(`${proto}://${address}:${port}`),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
} else if (inDocker) {
|
|
35
|
+
lines.push(chalk.hex('#4cc9f0')(' Docker ') + chalk.bold.yellow('Port mapped on host'));
|
|
36
|
+
} else {
|
|
37
|
+
lines.push(
|
|
38
|
+
chalk.hex('#4cc9f0')(' Local ') + chalk.bold.white(`${proto}://localhost:${port}`),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
lines.push(chalk.hex('#4cc9f0')(' Status ') + chalk.bold.green('● Online'));
|
|
43
|
+
|
|
44
|
+
console.log(
|
|
45
|
+
boxen(lines.join('\n'), {
|
|
46
|
+
padding: { left: 1, right: 1, top: 0, bottom: 0 },
|
|
47
|
+
borderColor: '#7b2dff',
|
|
48
|
+
borderStyle: 'round',
|
|
49
|
+
title: chalk.bold.hex('#00ff9f')(' SERVER '),
|
|
50
|
+
titleAlignment: 'center',
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
console.log();
|
|
55
|
+
if (inDocker && ips.length === 0) {
|
|
56
|
+
console.log(chalk.yellow(' Clients must connect using the host machine IP.'));
|
|
57
|
+
console.log(chalk.dim(` e.g. ${proto}://<HOST-IP>:${port}`));
|
|
58
|
+
console.log(chalk.dim(' Tip: set ADVERTISE_IP in .env to display the IP here.'));
|
|
59
|
+
}
|
|
60
|
+
if (ips.some((ip) => ip.tailscale)) {
|
|
61
|
+
console.log(
|
|
62
|
+
chalk.dim(' Tailscale IP detected — peers outside the LAN can connect through it.'),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
console.log(chalk.dim(' Zero-knowledge relay — the server does NOT read messages.'));
|
|
66
|
+
console.log(chalk.dim(' It only relays encrypted payloads between peers.'));
|
|
67
|
+
console.log();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function clientBanner() {
|
|
71
|
+
console.clear();
|
|
72
|
+
console.log(logo());
|
|
73
|
+
console.log(cyber(' ░▒▓ End-to-End Encrypted Chat ▓▒░'));
|
|
74
|
+
console.log();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const NEON_STOPS = ['#00ff9f', '#00b8ff', '#7b2dff', '#ff2dff'];
|
|
78
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
79
|
+
|
|
80
|
+
// Animated splash: the neon gradient "flows" through the ANSI-Shadow logo for
|
|
81
|
+
// ~1s, then settles. Runs before the TUI (plain terminal output).
|
|
82
|
+
export async function animatedBanner(subtitle = ' ░▒▓ End-to-End Encrypted Chat ▓▒░') {
|
|
83
|
+
const art = figlet.textSync('CipherMesh', { font: 'ANSI Shadow' }).replace(/\n+$/, '');
|
|
84
|
+
const artLines = art.split('\n');
|
|
85
|
+
const N = artLines.length;
|
|
86
|
+
|
|
87
|
+
// Some terminals/CI aren't interactive — just render the static banner.
|
|
88
|
+
if (!process.stdout.isTTY) {
|
|
89
|
+
console.clear();
|
|
90
|
+
console.log(neon(art));
|
|
91
|
+
console.log(cyber(subtitle));
|
|
92
|
+
console.log();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.clear();
|
|
97
|
+
const FRAMES = 22;
|
|
98
|
+
for (let f = 0; f < FRAMES; f++) {
|
|
99
|
+
const shift = f % NEON_STOPS.length;
|
|
100
|
+
const stops = [...NEON_STOPS.slice(shift), ...NEON_STOPS.slice(0, shift)];
|
|
101
|
+
const grad = gradient(stops);
|
|
102
|
+
if (f > 0) {
|
|
103
|
+
process.stdout.write(`\x1b[${N + 1}A`); // back to the top of the art
|
|
104
|
+
}
|
|
105
|
+
process.stdout.write(grad.multiline(art) + '\n');
|
|
106
|
+
process.stdout.write(cyber(subtitle) + '\n');
|
|
107
|
+
await sleep(45);
|
|
108
|
+
}
|
|
109
|
+
// Settle on the canonical neon gradient.
|
|
110
|
+
process.stdout.write(`\x1b[${N + 1}A`);
|
|
111
|
+
process.stdout.write(neon(art) + '\n');
|
|
112
|
+
process.stdout.write(cyber(subtitle) + '\n');
|
|
113
|
+
console.log();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Linear interpolation between two #rrggbb colors (t in [0,1]).
|
|
117
|
+
function mixHex(a, b, t) {
|
|
118
|
+
const pa = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16));
|
|
119
|
+
const pb = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16));
|
|
120
|
+
return (
|
|
121
|
+
'#' +
|
|
122
|
+
pa
|
|
123
|
+
.map((v, i) =>
|
|
124
|
+
Math.round(v + (pb[i] - v) * t)
|
|
125
|
+
.toString(16)
|
|
126
|
+
.padStart(2, '0'),
|
|
127
|
+
)
|
|
128
|
+
.join('')
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Goodbye animation on /quit: the neon logo fades to black over ~0.8s while a
|
|
133
|
+
// farewell line shows, then the process exits. Static fallback on non-TTY.
|
|
134
|
+
export async function farewellBanner(message = ' 🔒 Session ended — keys wiped from memory') {
|
|
135
|
+
const art = figlet.textSync('CipherMesh', { font: 'ANSI Shadow' }).replace(/\n+$/, '');
|
|
136
|
+
const N = art.split('\n').length;
|
|
137
|
+
|
|
138
|
+
if (!process.stdout.isTTY) {
|
|
139
|
+
console.log(neon(art));
|
|
140
|
+
console.log(cyber(message));
|
|
141
|
+
console.log();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.clear();
|
|
146
|
+
const FRAMES = 14;
|
|
147
|
+
for (let f = 0; f < FRAMES; f++) {
|
|
148
|
+
const t = f / (FRAMES - 1); // 0 → 1 (bright → dark)
|
|
149
|
+
const stops = NEON_STOPS.map((c) => mixHex(c, '#0d0d1a', t));
|
|
150
|
+
const grad = gradient(stops);
|
|
151
|
+
if (f > 0) {
|
|
152
|
+
process.stdout.write(`\x1b[${N + 1}A`);
|
|
153
|
+
}
|
|
154
|
+
process.stdout.write(grad.multiline(art) + '\n');
|
|
155
|
+
process.stdout.write(cyber(message) + '\n');
|
|
156
|
+
await sleep(55);
|
|
157
|
+
}
|
|
158
|
+
console.log();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const BOOT_STEPS = [
|
|
162
|
+
'Curve25519 key exchange',
|
|
163
|
+
'XSalsa20-Poly1305 cipher',
|
|
164
|
+
'Double Ratchet — forward secrecy',
|
|
165
|
+
'Secure memory (sodium_malloc)',
|
|
166
|
+
'TOFU trust store',
|
|
167
|
+
];
|
|
168
|
+
const BOOT_SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴'];
|
|
169
|
+
const BOOT_FRAME_MS = 70; // per spinner frame
|
|
170
|
+
const BOOT_SPINS = 1; // minimum full spinner cycles before a step "checks in"
|
|
171
|
+
const BOOT_BEAT_MS = 110; // pause after each ✓ so it registers
|
|
172
|
+
|
|
173
|
+
// Run a step's real work, but stop waiting after `ms` (the underlying promise
|
|
174
|
+
// keeps running in the background — e.g. a connection that's still retrying).
|
|
175
|
+
// Never rejects: resolves to true on success, false on error/timeout.
|
|
176
|
+
function runStep(task, ms) {
|
|
177
|
+
const work = Promise.resolve()
|
|
178
|
+
.then(task)
|
|
179
|
+
.then(
|
|
180
|
+
() => true,
|
|
181
|
+
() => false,
|
|
182
|
+
);
|
|
183
|
+
if (!ms) {
|
|
184
|
+
return work;
|
|
185
|
+
}
|
|
186
|
+
return Promise.race([work, new Promise((resolve) => setTimeout(() => resolve(false), ms))]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Cyberpunk boot sequence: the crypto stack "checks in" one component at a time
|
|
190
|
+
// before the TUI takes over. Each step may carry a real async `task` — the ✓
|
|
191
|
+
// then means it genuinely completed; a spinner floor keeps the deliberate pace
|
|
192
|
+
// for instant (local) steps, and slow ones (plugins, relay connect) extend it.
|
|
193
|
+
// Steps are strings (cosmetic) or { label, task?, timeoutMs? }. Static list on
|
|
194
|
+
// non-TTY.
|
|
195
|
+
export async function bootSequence(steps = BOOT_STEPS) {
|
|
196
|
+
const norm = steps.map((s) => (typeof s === 'string' ? { label: s } : s));
|
|
197
|
+
|
|
198
|
+
if (!process.stdout.isTTY) {
|
|
199
|
+
for (const step of norm) {
|
|
200
|
+
const ok = step.task ? await runStep(step.task, step.timeoutMs) : true;
|
|
201
|
+
console.log((ok ? chalk.green(' ✓ ') : chalk.yellow(' … ')) + chalk.dim(step.label));
|
|
202
|
+
}
|
|
203
|
+
console.log();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const minFrames = BOOT_SPINNER.length * BOOT_SPINS;
|
|
208
|
+
for (const step of norm) {
|
|
209
|
+
let settled = !step.task;
|
|
210
|
+
let ok = true;
|
|
211
|
+
const work = step.task
|
|
212
|
+
? runStep(step.task, step.timeoutMs).then((r) => {
|
|
213
|
+
settled = true;
|
|
214
|
+
ok = r;
|
|
215
|
+
})
|
|
216
|
+
: null;
|
|
217
|
+
|
|
218
|
+
// Spin at least `minFrames`, and keep spinning until the real work settles.
|
|
219
|
+
for (let i = 0; i < minFrames || !settled; i++) {
|
|
220
|
+
const glyph = BOOT_SPINNER[i % BOOT_SPINNER.length];
|
|
221
|
+
process.stdout.write(`\r ${chalk.cyan(glyph)} ${chalk.dim(step.label)} `);
|
|
222
|
+
await sleep(BOOT_FRAME_MS);
|
|
223
|
+
}
|
|
224
|
+
if (work) {
|
|
225
|
+
await work;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const mark = ok ? chalk.green('✓') : chalk.yellow('…');
|
|
229
|
+
const label = ok ? chalk.white(step.label) : chalk.dim(step.label);
|
|
230
|
+
process.stdout.write(`\r ${mark} ${label} \n`);
|
|
231
|
+
await sleep(BOOT_BEAT_MS);
|
|
232
|
+
}
|
|
233
|
+
console.log(chalk.hex('#00ff9f')(' ▸ Secure session ready') + '\n');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function clientConnectingBox(wsUrl, fingerprint) {
|
|
237
|
+
const lines = [];
|
|
238
|
+
lines.push(chalk.hex('#4cc9f0')(' Server ') + chalk.bold.white(wsUrl));
|
|
239
|
+
lines.push(chalk.hex('#4cc9f0')(' Fingerprint ') + mint(fingerprint));
|
|
240
|
+
lines.push(chalk.hex('#4cc9f0')(' Crypto ') + chalk.white('X25519 + XSalsa20-Poly1305'));
|
|
241
|
+
lines.push(chalk.hex('#4cc9f0')(' Status ') + chalk.bold.yellow('● Connecting...'));
|
|
242
|
+
|
|
243
|
+
console.log(
|
|
244
|
+
boxen(lines.join('\n'), {
|
|
245
|
+
padding: { left: 1, right: 1, top: 0, bottom: 0 },
|
|
246
|
+
borderColor: '#7b2dff',
|
|
247
|
+
borderStyle: 'round',
|
|
248
|
+
title: chalk.bold.hex('#00ff9f')(' CLIENT '),
|
|
249
|
+
titleAlignment: 'center',
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
console.log();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function promptLabel(text) {
|
|
256
|
+
return chalk.hex('#00ff9f')(' ▸ ') + chalk.bold.white(text);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function promptDim(text) {
|
|
260
|
+
return chalk.dim(text);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function promptError(text) {
|
|
264
|
+
return chalk.red(' ✗ ') + chalk.red(text);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function promptSuccess(text) {
|
|
268
|
+
return chalk.green(' ✓ ') + chalk.green(text);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export { chalk, neon, cyber, mint };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Levenshtein edit distance between two short strings.
|
|
2
|
+
function editDistance(a, b) {
|
|
3
|
+
const m = a.length;
|
|
4
|
+
const n = b.length;
|
|
5
|
+
if (m === 0) {
|
|
6
|
+
return n;
|
|
7
|
+
}
|
|
8
|
+
if (n === 0) {
|
|
9
|
+
return m;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let prev = Array.from({ length: n + 1 }, (_, i) => i);
|
|
13
|
+
let curr = new Array(n + 1);
|
|
14
|
+
|
|
15
|
+
for (let i = 1; i <= m; i++) {
|
|
16
|
+
curr[0] = i;
|
|
17
|
+
for (let j = 1; j <= n; j++) {
|
|
18
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
19
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
20
|
+
}
|
|
21
|
+
[prev, curr] = [curr, prev];
|
|
22
|
+
}
|
|
23
|
+
return prev[n];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Suggest the closest known command to an unknown one.
|
|
28
|
+
* Returns the nearest command within a small edit distance, or null.
|
|
29
|
+
* @param {string} input - the command the user typed (e.g. "/exti")
|
|
30
|
+
* @param {string[]} commands - canonical command list (e.g. ["/exit", ...])
|
|
31
|
+
* @param {number} maxDistance - max edit distance to consider a match
|
|
32
|
+
* @returns {string|null}
|
|
33
|
+
*/
|
|
34
|
+
export function suggestCommand(input, commands, maxDistance = 3) {
|
|
35
|
+
if (typeof input !== 'string' || !Array.isArray(commands)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const needle = input.toLowerCase();
|
|
39
|
+
|
|
40
|
+
let best = null;
|
|
41
|
+
let bestScore = Infinity;
|
|
42
|
+
|
|
43
|
+
for (const cmd of commands) {
|
|
44
|
+
const cand = cmd.toLowerCase();
|
|
45
|
+
if (cand === needle) {
|
|
46
|
+
return null; // exact match — nothing to suggest
|
|
47
|
+
}
|
|
48
|
+
// Strong signal: one is a prefix of the other (typo by truncation/extra char)
|
|
49
|
+
const prefixBonus = cand.startsWith(needle) || needle.startsWith(cand) ? -1 : 0;
|
|
50
|
+
const score = editDistance(needle, cand) + prefixBonus;
|
|
51
|
+
if (score < bestScore) {
|
|
52
|
+
bestScore = score;
|
|
53
|
+
best = cmd;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Cap distance relative to command length so long words don't over-match
|
|
58
|
+
return bestScore <= maxDistance ? best : null;
|
|
59
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Optional user config at ~/.ciphermesh/config.json. Everything is a default the
|
|
6
|
+
// user can still override at the prompt or with a slash-command. Unknown keys
|
|
7
|
+
// are ignored (whitelist) so a typo can never inject behaviour.
|
|
8
|
+
const ALLOWED = [
|
|
9
|
+
'nickname',
|
|
10
|
+
'server',
|
|
11
|
+
'sound',
|
|
12
|
+
'notify',
|
|
13
|
+
'cover',
|
|
14
|
+
'receipts',
|
|
15
|
+
'deniable',
|
|
16
|
+
'theme',
|
|
17
|
+
'autoAway',
|
|
18
|
+
'dnd',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function configPath() {
|
|
22
|
+
return join(homedir(), '.ciphermesh', 'config.json');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Parse + whitelist a raw config string. Pure — exported for testing. */
|
|
26
|
+
export function parseConfig(raw) {
|
|
27
|
+
let obj;
|
|
28
|
+
try {
|
|
29
|
+
obj = JSON.parse(raw);
|
|
30
|
+
} catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const k of ALLOWED) {
|
|
38
|
+
if (obj[k] !== undefined) {
|
|
39
|
+
out[k] = obj[k];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Read the config file, returning {} if missing or unreadable. */
|
|
46
|
+
export function loadConfig(path = configPath()) {
|
|
47
|
+
try {
|
|
48
|
+
return parseConfig(readFileSync(path, 'utf-8'));
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Translate config toggles into the slash-commands that apply them, so startup
|
|
56
|
+
* reuses the exact command handlers (no duplicated logic). Pure — testable.
|
|
57
|
+
*/
|
|
58
|
+
export function startupCommands(config) {
|
|
59
|
+
const cmds = [];
|
|
60
|
+
if (config.sound === false) {
|
|
61
|
+
cmds.push('/sound off');
|
|
62
|
+
} else if (config.sound === true) {
|
|
63
|
+
cmds.push('/sound on');
|
|
64
|
+
}
|
|
65
|
+
if (config.notify === false) {
|
|
66
|
+
cmds.push('/notify off');
|
|
67
|
+
} else if (config.notify === true) {
|
|
68
|
+
cmds.push('/notify on');
|
|
69
|
+
}
|
|
70
|
+
if (config.receipts === false) {
|
|
71
|
+
cmds.push('/receipts off');
|
|
72
|
+
}
|
|
73
|
+
if (config.deniable === true) {
|
|
74
|
+
cmds.push('/deniable on');
|
|
75
|
+
}
|
|
76
|
+
if (config.cover === 'on' || config.cover === 'jitter') {
|
|
77
|
+
cmds.push('/cover on');
|
|
78
|
+
} else if (config.cover === 'constant') {
|
|
79
|
+
cmds.push('/cover constant');
|
|
80
|
+
}
|
|
81
|
+
if (Number.isInteger(config.autoAway) && config.autoAway > 0) {
|
|
82
|
+
cmds.push(`/autoaway ${config.autoAway}`);
|
|
83
|
+
}
|
|
84
|
+
if (config.dnd === 'on' || config.dnd === 'mentions') {
|
|
85
|
+
cmds.push(`/dnd ${config.dnd}`);
|
|
86
|
+
} else if (typeof config.dnd === 'string' && /^\d/.test(config.dnd)) {
|
|
87
|
+
cmds.push(`/dnd ${config.dnd}`);
|
|
88
|
+
}
|
|
89
|
+
return cmds;
|
|
90
|
+
}
|