ciphermesh 2.6.0 → 2.7.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,69 @@
1
+ # Changelog
2
+
3
+ Notable changes per release. Older versions are reconstructed from the git
4
+ history — the commit bodies and pull requests remain the fuller record.
5
+
6
+ ## 2.7.1
7
+
8
+ ### Fixed
9
+
10
+ - The banner no longer reads a font from disk. `figlet.textSync` loads
11
+ `fonts/ANSI Shadow.flf` at startup — fine under Node, fatal inside a compiled
12
+ binary, where the file does not exist and the process died before printing
13
+ anything. The word never changes, so the art is now the constant it always
14
+ was, pinned against figlet's own output by a test. figlet moved to a
15
+ devDependency and the 1.11.4 hold is gone (#414).
16
+
17
+ ## 2.7.0
18
+
19
+ ### Added
20
+
21
+ - **Public hub at [ciphermesh.de](https://ciphermesh.de).** An always-on relay
22
+ anyone can connect to, so using CipherMesh no longer means already knowing
23
+ someone who runs one. `general` is the default room. Governed by
24
+ [TERMS.md](TERMS.md).
25
+ - **The website and the relay share one domain.** Caddy routes WebSocket
26
+ upgrades to the relay and everything else to the landing page. No client
27
+ change: the client connects to `/` with no path, so the split is on the
28
+ `Upgrade` handshake rather than a path.
29
+ - **Presence endpoint** (`PRESENCE_PORT`, off by default). Publishes how busy a
30
+ relay is as a coarse **range** — `1-5`, `6-20` — never an exact count, a room
31
+ name or a nickname. An exact live number would let anyone polling it watch
32
+ people arrive and leave, which is the metadata the relay exists to withhold.
33
+ Runs on its own listener so it cannot affect chat.
34
+ - **Standalone client binary.** Every release now ships
35
+ `ciphermesh-<platform>` with the client, relay and P2P, alongside the
36
+ relay-only `ciphermesh-server-<platform>`. No Node needed. Closes the
37
+ long-standing bundling blocker (#328).
38
+ - **External hub monitoring.** A scheduled probe checks the WebSocket upgrade,
39
+ the site and the certificate, and opens an issue when any of them fails.
40
+
41
+ ### Fixed
42
+
43
+ - A Caddyfile change never reached the running Caddy. `git reset --hard`
44
+ replaces the file, giving it a new inode, and a file bind-mount follows the
45
+ inode it was created with — the container kept reading the old, unlinked copy
46
+ while `docker compose up -d` saw nothing to recreate. `deploy/deploy.sh` is
47
+ now versioned and recreates Caddy when the file changes.
48
+ - The automatic deploy never fired on a release: GitHub does not trigger
49
+ workflows from events created by `GITHUB_TOKEN`.
50
+
51
+ ### Changed
52
+
53
+ - Both READMEs describe the hub and link to the site; `homepage` now points at
54
+ ciphermesh.de.
55
+ - `bonjour-service` 1.4.3 → 1.4.4.
56
+ - **`figlet` held at 1.11.3.** 1.11.4 reads its `.flf` font from disk at
57
+ runtime, and that file is not inside a compiled binary, so every standalone
58
+ build died with `ENOENT` on `/$bunfs/fonts/ANSI Shadow.flf` before printing
59
+ the banner. The test suite runs under Node, where the font exists, so CI
60
+ stayed green while the binaries were broken. Tracked in #414.
61
+ - Dependabot now targets `dev`. It was opening PRs straight into `master`,
62
+ which left `dev` behind and dragged unrelated commits into the next release.
63
+
64
+ ## 2.6.0 and earlier
65
+
66
+ See the [release history](https://github.com/FelipeKreulich/secret-chat-lan/releases).
67
+ Highlights: hybrid post-quantum handshake (X25519 + ML-KEM-768), sealed sender,
68
+ multi-room buffers, private rooms with zero-knowledge passwords, the plugin API,
69
+ `/doctor`, screen lock, and the standalone relay binary.
package/README.md CHANGED
@@ -15,8 +15,11 @@
15
15
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
16
16
  [![Node](https://img.shields.io/badge/node-%E2%89%A5%2020-brightgreen)](package.json)
17
17
  [![Crypto](https://img.shields.io/badge/E2EE-libsodium-7b2dff)](docs/ARCHITECTURE.md)
18
+ [![Website](https://img.shields.io/badge/ciphermesh.de-4cc9f0)](https://ciphermesh.de)
18
19
 
19
- **[🇧🇷 Leia em Português](README.pt-BR.md)** · [Setup Guide](docs/SETUP.md) · [Architecture](docs/ARCHITECTURE.md) · [Security Policy](SECURITY.md) · [Hub Terms](TERMS.md)
20
+ **[ciphermesh.de](https://ciphermesh.de)** the website, and the public hub anyone can join.
21
+
22
+ **[🇧🇷 Leia em Português](README.pt-BR.md)** · [Setup Guide](docs/SETUP.md) · [Architecture](docs/ARCHITECTURE.md) · [Security Policy](SECURITY.md) · [Hub Terms](TERMS.md) · [Contributing](CONTRIBUTING.md)
20
23
 
21
24
  <img src="docs/demo.svg" alt="CipherMesh terminal demo" width="720">
22
25
 
@@ -119,12 +122,15 @@ the server against a real CA (no trust-on-first-use window), and a host that
119
122
  once served a valid certificate can never be silently downgraded to a
120
123
  self-signed one.
121
124
 
122
- **No Node on the host?** Every release ships a standalone relay binary for
123
- macOS and Linux (arm64/x64) — download it from the
125
+ **No Node at all?** Every release ships standalone binaries for macOS and Linux
126
+ (arm64/x64) — download from the
124
127
  [releases page](https://github.com/FelipeKreulich/secret-chat-lan/releases),
125
- `chmod +x`, run. Nothing to install. (The TUI client still needs Node/npx: it
126
- depends on blessed, which resolves its widgets at runtime and cannot be
127
- bundled.)
128
+ `chmod +x`, run. Nothing to install, not even Node.
129
+
130
+ | Binary | What it is |
131
+ |---|---|
132
+ | `ciphermesh-<platform>` | Everything: client, relay and P2P. `ciphermesh server` and `ciphermesh p2p` work exactly as they do on npm. |
133
+ | `ciphermesh-server-<platform>` | Relay only, for self-hosters who want nothing else on the machine. |
128
134
 
129
135
  **Everyone** (including the host):
130
136
 
@@ -145,6 +151,35 @@ now speaks nearly the same command set as the relay client (presence, `/lock`,
145
151
  `/contacts`, `/mentions`, `/topic`, encrypted history and more); only
146
152
  `/invite`, `/create` and `/nick` are relay-specific by nature.
147
153
 
154
+ ## 🌐 The community lives at ciphermesh.de
155
+
156
+ For a long time, using CipherMesh meant already knowing someone who ran a relay.
157
+ That is over: **[ciphermesh.de](https://ciphermesh.de)** is a public hub, free
158
+ and open to anyone, always on. Nobody has to coordinate who starts the server.
159
+
160
+ ```bash
161
+ npx ciphermesh@latest
162
+ # Server: ciphermesh.de
163
+ # Room: general
164
+ ```
165
+
166
+ - **`general` is the front door.** It is the default room — connect and say hello
167
+ if you just want to see who else is around.
168
+ - **Any room, opened by anyone.** Pick a name, tell the people you want in it,
169
+ start talking. No account, no invitation, no waiting list.
170
+ - **Private the moment you want it.** Add a password and the room is yours. The
171
+ password never reaches the server — only an Argon2id-derived verifier it cannot
172
+ reverse.
173
+ - **Nothing outlives the conversation.** A room exists while someone is inside
174
+ it. When the last person leaves, the room and everything said in it are gone.
175
+
176
+ Running the hub does not make its operator a reader: it is zero-knowledge like
177
+ every other CipherMesh relay — the only difference is that this one never goes to
178
+ sleep. Use is governed by the **[terms](TERMS.md)**.
179
+
180
+ Bring people in: `/invite` prints a joinable string and a QR code, and
181
+ `/rooms` shows what is live.
182
+
148
183
  ## 💬 Commands
149
184
 
150
185
  <details>
package/README.pt-BR.md CHANGED
@@ -15,8 +15,11 @@
15
15
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
16
16
  [![Node](https://img.shields.io/badge/node-%E2%89%A5%2020-brightgreen)](package.json)
17
17
  [![Crypto](https://img.shields.io/badge/E2EE-libsodium-7b2dff)](docs/ARCHITECTURE.md)
18
+ [![Site](https://img.shields.io/badge/ciphermesh.de-4cc9f0)](https://ciphermesh.de)
18
19
 
19
- **[🇺🇸 Read in English](README.md)** · [Guia de Setup](docs/SETUP.md) · [Arquitetura](docs/ARCHITECTURE.md) · [Política de Segurança](SECURITY.md) · [Termos do Hub](TERMS.md)
20
+ **[ciphermesh.de](https://ciphermesh.de)** o site, e o hub público em que qualquer pessoa pode entrar.
21
+
22
+ **[🇺🇸 Read in English](README.md)** · [Guia de Setup](docs/SETUP.md) · [Arquitetura](docs/ARCHITECTURE.md) · [Política de Segurança](SECURITY.md) · [Termos do Hub](TERMS.md) · [Como contribuir](CONTRIBUTING.md)
20
23
 
21
24
  <img src="docs/demo.svg" alt="Demo do CipherMesh no terminal" width="720">
22
25
 
@@ -120,12 +123,15 @@ clientes verificam o servidor contra uma CA de verdade (sem janela de
120
123
  trust-on-first-use), e um host que já apresentou certificado válido nunca pode
121
124
  ser rebaixado silenciosamente para um self-signed.
122
125
 
123
- **Sem Node na máquina?** Todo release traz um binário standalone do relay para
124
- macOS e Linux (arm64/x64) — baixe da
126
+ **Sem Node nenhum?** Todo release traz binários standalone para macOS e Linux
127
+ (arm64/x64) — baixe da
125
128
  [página de releases](https://github.com/FelipeKreulich/secret-chat-lan/releases),
126
- `chmod +x`, rode. Nada para instalar. (O cliente TUI ainda precisa de Node/npx:
127
- ele depende do blessed, que resolve os widgets em runtime e não pode ser
128
- empacotado.)
129
+ `chmod +x`, rode. Nada para instalar, nem Node.
130
+
131
+ | Binário | O que é |
132
+ |---|---|
133
+ | `ciphermesh-<plataforma>` | Tudo: cliente, relay e P2P. `ciphermesh server` e `ciphermesh p2p` funcionam igual ao npm. |
134
+ | `ciphermesh-server-<plataforma>` | Só o relay, para quem hospeda e não quer mais nada na máquina. |
129
135
 
130
136
  **Todo mundo** (incluindo quem hospeda):
131
137
 
@@ -146,6 +152,36 @@ P2P agora fala quase o mesmo conjunto de comandos do cliente relay (presença,
146
152
  `/lock`, `/contacts`, `/mentions`, `/topic`, histórico cifrado e mais); só
147
153
  `/invite`, `/create` e `/nick` são específicos do relay por natureza.
148
154
 
155
+ ## 🌐 A comunidade fica no ciphermesh.de
156
+
157
+ Durante muito tempo, usar o CipherMesh exigia já conhecer alguém que rodasse um
158
+ relay. Isso acabou: **[ciphermesh.de](https://ciphermesh.de)** é um hub público,
159
+ gratuito, aberto a qualquer pessoa e sempre no ar. Ninguém precisa combinar quem
160
+ vai subir o servidor.
161
+
162
+ ```bash
163
+ npx ciphermesh@latest
164
+ # Server: ciphermesh.de
165
+ # Room: general
166
+ ```
167
+
168
+ - **A `general` é a porta de entrada.** É a sala padrão — conecte e diga oi se
169
+ você só quer ver quem está por perto.
170
+ - **Qualquer sala, aberta por qualquer um.** Escolha um nome, avise quem você quer
171
+ lá dentro e comece a conversar. Sem conta, sem convite, sem lista de espera.
172
+ - **Privada na hora que você quiser.** Ponha uma senha e a sala é sua. A senha
173
+ nunca chega ao servidor — ele guarda só um verificador derivado com Argon2id,
174
+ que não dá para reverter.
175
+ - **Nada sobrevive à conversa.** A sala existe enquanto alguém está dentro dela.
176
+ Quando a última pessoa sai, a sala e tudo o que foi dito nela desaparecem.
177
+
178
+ Manter o hub não transforma quem o opera em leitor: ele é zero-knowledge como
179
+ qualquer outro relay do CipherMesh — a única diferença é que este nunca dorme. O
180
+ uso é regido pelos **[termos](TERMS.md)**.
181
+
182
+ Para chamar gente: `/invite` imprime uma string de entrada e um QR code, e
183
+ `/rooms` mostra o que está no ar.
184
+
149
185
  ## 💬 Comandos
150
186
 
151
187
  <details>
@@ -0,0 +1,42 @@
1
+ // Entry point for the standalone CipherMesh binary (`bun build --compile`).
2
+ //
3
+ // Separate from bin/ciphermesh.js because that one dispatches through
4
+ // `await import(new URL(target, import.meta.url))` — a computed specifier no
5
+ // bundler can follow, which is why the binary used to contain nothing but the
6
+ // launcher. Here the three specifiers are literal, so the bundler pulls in all
7
+ // three modes and the branch picks one at runtime.
8
+ //
9
+ // The version is injected at build time: reading package.json off disk does not
10
+ // work once everything lives inside the binary.
11
+ const VERSION = process.env.CIPHERMESH_VERSION || 'unknown';
12
+
13
+ const HELP = `CipherMesh — end-to-end encrypted terminal chat
14
+
15
+ Usage:
16
+ ciphermesh [client] connect to a relay server (default)
17
+ ciphermesh server run the zero-knowledge relay server
18
+ ciphermesh p2p serverless mode (mDNS peer discovery on the LAN)
19
+
20
+ Options:
21
+ -h, --help show this help
22
+ -v, --version show the version
23
+
24
+ Client options:
25
+ --setup re-run the first-run setup wizard
26
+ --no-onboard skip the first-run wizard (scripts/CI)
27
+ --fresh forget the last session (server/room) and start clean
28
+ `;
29
+
30
+ const arg = process.argv[2];
31
+
32
+ if (arg === '-h' || arg === '--help' || arg === 'help') {
33
+ process.stdout.write(HELP);
34
+ } else if (arg === '-v' || arg === '--version') {
35
+ process.stdout.write(`${VERSION}\n`);
36
+ } else if (arg === 'server') {
37
+ await import('../src/server/index.js');
38
+ } else if (arg === 'p2p') {
39
+ await import('../src/p2p/index.js');
40
+ } else {
41
+ await import('../src/client/index.js');
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ciphermesh",
3
- "version": "2.6.0",
3
+ "version": "2.7.1",
4
4
  "description": "Secure terminal chat for the local network (LAN) with real end-to-end encryption (E2EE) using libsodium",
5
5
  "type": "module",
6
6
  "main": "src/client/index.js",
@@ -14,7 +14,8 @@
14
14
  "examples",
15
15
  "README.md",
16
16
  "README.pt-BR.md",
17
- "LICENSE"
17
+ "LICENSE",
18
+ "CHANGELOG.md"
18
19
  ],
19
20
  "engines": {
20
21
  "node": ">=20.0.0"
@@ -26,7 +27,7 @@
26
27
  "type": "git",
27
28
  "url": "git+https://github.com/FelipeKreulich/secret-chat-lan.git"
28
29
  },
29
- "homepage": "https://github.com/FelipeKreulich/secret-chat-lan#readme",
30
+ "homepage": "https://ciphermesh.de",
30
31
  "bugs": {
31
32
  "url": "https://github.com/FelipeKreulich/secret-chat-lan/issues"
32
33
  },
@@ -53,10 +54,9 @@
53
54
  "dependencies": {
54
55
  "@noble/post-quantum": "0.6.1",
55
56
  "blessed": "0.1.81",
56
- "bonjour-service": "1.4.3",
57
+ "bonjour-service": "1.4.4",
57
58
  "boxen": "8.0.1",
58
59
  "chalk": "^5.4.1",
59
- "figlet": "1.11.3",
60
60
  "gradient-string": "3.0.0",
61
61
  "jimp": "1.6.1",
62
62
  "node-notifier": "10.0.1",
@@ -67,6 +67,7 @@
67
67
  "devDependencies": {
68
68
  "@eslint/js": "10.0.1",
69
69
  "eslint": "10.8.0",
70
+ "figlet": "^1.11.4",
70
71
  "globals": "^17.7.0",
71
72
  "prettier": "^3.9.5"
72
73
  },
@@ -9,6 +9,7 @@ import { SessionManager } from './SessionManager.js';
9
9
  import { MessageRouter } from './MessageRouter.js';
10
10
  import { OfflineQueue } from './OfflineQueue.js';
11
11
  import { SecureWSServer } from './WebSocketServer.js';
12
+ import { startPresenceServer } from './presence.js';
12
13
  import { loadOrGenerateCerts } from './CertManager.js';
13
14
 
14
15
  const log = createLogger('server');
@@ -55,6 +56,12 @@ function getLocalIPs() {
55
56
  // ── Bootstrap ──────────────────────────────────────────────────
56
57
  const port = parseInt(process.env.PORT, 10) || SERVER_PORT;
57
58
 
59
+ /** A port number, or 0 meaning "do not listen". */
60
+ function positivePort(value) {
61
+ const n = parseInt(value, 10);
62
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : 0;
63
+ }
64
+
58
65
  const useTls = process.env.TLS !== 'false'; // default: true
59
66
  const tlsOptions = useTls ? loadOrGenerateCerts() : null;
60
67
 
@@ -63,6 +70,11 @@ const offlineQueue = new OfflineQueue();
63
70
  const messageRouter = new MessageRouter(sessionManager, offlineQueue);
64
71
  const server = new SecureWSServer(sessionManager, messageRouter, offlineQueue, port, tlsOptions);
65
72
 
73
+ // Optional, off unless PRESENCE_PORT is set. Publishes how busy the hub is as a
74
+ // bucket — never a count, never a room name. Runs on its own listener so a
75
+ // problem here cannot reach the chat.
76
+ startPresenceServer(sessionManager, positivePort(process.env.PRESENCE_PORT));
77
+
66
78
  // Cleanup offline queue and recently left peers every 5 minutes
67
79
  setInterval(
68
80
  () => {
@@ -0,0 +1,79 @@
1
+ import { createServer } from 'node:http';
2
+ import { createLogger } from '../shared/logger.js';
3
+
4
+ const log = createLogger('presence');
5
+
6
+ /**
7
+ * Buckets, not counts.
8
+ *
9
+ * A live "3 people online" would let anyone with a polling script watch people
10
+ * arrive and leave — exactly the metadata the relay is built not to hand out.
11
+ * Bucketing hides that: within a bucket, joins and leaves are invisible, and the
12
+ * only thing an observer learns is roughly how busy the hub is.
13
+ *
14
+ * Crossing a boundary is still observable (5 → 6 says somebody joined). That is
15
+ * inherent to publishing anything at all, and it is a far smaller disclosure
16
+ * than an exact number. Wider buckets would leak less and say less; this is the
17
+ * trade-off, stated out loud rather than hidden.
18
+ */
19
+ const BUCKETS = [
20
+ { max: 0, label: '0' },
21
+ { max: 5, label: '1-5' },
22
+ { max: 20, label: '6-20' },
23
+ { max: 50, label: '21-50' },
24
+ { max: Infinity, label: '50+' },
25
+ ];
26
+
27
+ export function bucketFor(count) {
28
+ const n = Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
29
+ return BUCKETS.find((b) => n <= b.max).label;
30
+ }
31
+
32
+ /**
33
+ * A tiny HTTP listener that publishes how busy the hub is, and nothing else.
34
+ *
35
+ * Deliberately a SEPARATE server from the WebSocket one: if anything here
36
+ * misbehaves, chat is untouched. It is meant to be reachable only from the
37
+ * internal network (the landing page's container asks it), never published to
38
+ * the host — see deploy/docker-compose.public.yml.
39
+ *
40
+ * What it will never expose: room names, nicknames, addresses, or an exact
41
+ * count. Those are the things the relay's whole design refuses to hand over,
42
+ * and a convenience endpoint is not a good reason to start.
43
+ *
44
+ * @param {{ size: number }} sessionManager
45
+ * @param {number} port - 0 or unset disables the listener entirely
46
+ */
47
+ export function startPresenceServer(sessionManager, port) {
48
+ if (!port) {
49
+ return null;
50
+ }
51
+
52
+ const server = createServer((req, res) => {
53
+ if (req.method !== 'GET' || !req.url.startsWith('/presence')) {
54
+ res.writeHead(404).end();
55
+ return;
56
+ }
57
+
58
+ const body = JSON.stringify({ online: bucketFor(sessionManager.size) });
59
+ res.writeHead(200, {
60
+ 'content-type': 'application/json',
61
+ // The caller caches; this only stops an intermediary from serving a
62
+ // stale bucket for longer than the bucket is meaningful.
63
+ 'cache-control': 'public, max-age=30',
64
+ 'content-length': Buffer.byteLength(body),
65
+ });
66
+ res.end(body);
67
+ });
68
+
69
+ server.on('error', (err) => {
70
+ // Presence is a nice-to-have. It must never take the relay down with it.
71
+ log.error(`Presence server error: ${err.message}`);
72
+ });
73
+
74
+ // Bind to all interfaces so the site's container can reach it over the Docker
75
+ // network. The port is never published to the host.
76
+ server.listen(port, () => log.info(`Presence endpoint on port ${port}`));
77
+
78
+ return server;
79
+ }
@@ -1,4 +1,3 @@
1
- import figlet from 'figlet';
2
1
  import gradient from 'gradient-string';
3
2
  import chalk from 'chalk';
4
3
  import boxen from 'boxen';
@@ -7,8 +6,36 @@ const neon = gradient(['#00ff9f', '#00b8ff', '#7b2dff', '#ff2dff']);
7
6
  const cyber = gradient(['#f72585', '#7209b7', '#3a0ca3', '#4361ee', '#4cc9f0']);
8
7
  const mint = gradient(['#00ff9f', '#00b8ff']);
9
8
 
9
+ /**
10
+ * "CipherMesh" in figlet's ANSI Shadow, rendered once and kept.
11
+ *
12
+ * It used to be generated at startup with `figlet.textSync`, which reads the
13
+ * font file from disk — fine under Node, fatal inside a compiled binary, where
14
+ * `fonts/ANSI Shadow.flf` does not exist and the process dies before printing
15
+ * anything (issue #414). The word never changes, so there is nothing to
16
+ * generate: the art is the constant it always was.
17
+ *
18
+ * `test/banner.test.js` pins it against figlet's own output, so a change here
19
+ * has to be deliberate.
20
+ */
21
+ const ART = [
22
+ ' ██████╗██╗██████╗ ██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗██╗ ██╗',
23
+ '██╔════╝██║██╔══██╗██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝██║ ██║',
24
+ '██║ ██║██████╔╝███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗███████║',
25
+ '██║ ██║██╔═══╝ ██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║██╔══██║',
26
+ '╚██████╗██║██║ ██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║██║ ██║',
27
+ ' ╚═════╝╚═╝╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝',
28
+ // figlet pads the block to a seventh, blank line. It is not decoration: the
29
+ // animations count lines to move the cursor back up (`\x1b[${N + 1}A`), so
30
+ // dropping it would shift every redraw by one row.
31
+ ' '.repeat(78),
32
+ ].join('\n');
33
+
34
+ /** Byte-for-byte what `figlet.textSync('CipherMesh', { font: 'ANSI Shadow' })` returns. */
35
+ export const LOGO_ART = ART;
36
+
10
37
  function logo() {
11
- return neon(figlet.textSync('CipherMesh', { font: 'ANSI Shadow' }));
38
+ return neon(LOGO_ART);
12
39
  }
13
40
 
14
41
  export function serverBanner(port, network, tls = false) {
@@ -80,7 +107,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
80
107
  // Animated splash: the neon gradient "flows" through the ANSI-Shadow logo for
81
108
  // ~1s, then settles. Runs before the TUI (plain terminal output).
82
109
  export async function animatedBanner(subtitle = ' ░▒▓ End-to-End Encrypted Chat ▓▒░') {
83
- const art = figlet.textSync('CipherMesh', { font: 'ANSI Shadow' }).replace(/\n+$/, '');
110
+ const art = ART;
84
111
  const artLines = art.split('\n');
85
112
  const N = artLines.length;
86
113
 
@@ -132,7 +159,7 @@ function mixHex(a, b, t) {
132
159
  // Goodbye animation on /quit: the neon logo fades to black over ~0.8s while a
133
160
  // farewell line shows, then the process exits. Static fallback on non-TTY.
134
161
  export async function farewellBanner(message = ' 🔒 Session ended — keys wiped from memory') {
135
- const art = figlet.textSync('CipherMesh', { font: 'ANSI Shadow' }).replace(/\n+$/, '');
162
+ const art = ART;
136
163
  const N = art.split('\n').length;
137
164
 
138
165
  if (!process.stdout.isTTY) {