ezshare-cli 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Blake
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,311 @@
1
+ # EzShare
2
+
3
+ **Secure P2P file transfer with end-to-end encryption - No servers, no signups, just share**
4
+
5
+ Share files and folders directly between peers using decentralized Hyperswarm DHT. Works across different networks with built-in NAT traversal.
6
+
7
+ [![Node.js](https://img.shields.io/badge/Node.js-18%2B-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
8
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.0-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
9
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
10
+
11
+ ## ✨ Features
12
+
13
+ - **🔒 End-to-End Encryption** — AES-256-GCM authenticated encryption
14
+ - **🌐 Truly Decentralized** — Direct peer-to-peer via Hyperswarm DHT, no servers
15
+ - **🚀 NAT Traversal** — Works across different networks with automatic hole-punching
16
+ - **⚡ Adaptive Compression** — Zstandard compression (smart detection skips .zip, .mp4, etc.)
17
+ - **💻 Interactive Shell** — Claude Code-style TUI with slash commands and file browser
18
+ - **📦 Streaming Architecture** — Memory-efficient for files of any size
19
+ - **🔄 Cross-Platform** — Linux, macOS, and Windows
20
+
21
+ ## 📦 Installation
22
+
23
+ ### Prerequisites
24
+
25
+ - **Node.js 18+** (recommended: Node 20+)
26
+ - **Zstandard** (`zstd`) - required for compression
27
+
28
+ ```bash
29
+ # Ubuntu/Debian
30
+ sudo apt install zstd
31
+
32
+ # macOS
33
+ brew install zstd
34
+
35
+ # Windows (chocolatey)
36
+ choco install zstd
37
+ ```
38
+
39
+ ### Install Globally
40
+
41
+ ```bash
42
+ npm install -g ezshare-cli
43
+ ```
44
+
45
+ Now you can use `ezshare` from anywhere!
46
+
47
+ ### Or Use with npx (no install)
48
+
49
+ ```bash
50
+ npx ezshare-cli
51
+ ```
52
+
53
+ ## 🚀 Quick Start
54
+
55
+ ### Interactive Mode (Recommended)
56
+
57
+ Simply type `ezshare` to launch the interactive shell:
58
+
59
+ ```bash
60
+ ezshare
61
+ ```
62
+
63
+ You'll see:
64
+
65
+ ```
66
+ EzShare CLI - P2P File Transfer
67
+ Type /ezshare to start or /help for commands
68
+
69
+ ezshare>
70
+ ```
71
+
72
+ **Available commands:**
73
+ - `/ezshare` - Open the main menu
74
+ - `/help` - Show help screen
75
+ - `/exit` - Exit the application
76
+ - `q` - Quick exit (when in command mode)
77
+ - `Esc` - Go back to command mode
78
+
79
+ #### Using the Interactive Shell
80
+
81
+ 1. Type `/ezshare` to open the main menu
82
+ 2. Choose **Send** or **Receive**
83
+ 3. **For Send:**
84
+ - Use arrow keys (↑/↓) to navigate your file system
85
+ - Press `Enter` on a folder to open it
86
+ - Press `Enter` on a file to select it for sharing
87
+ - Copy the generated share key
88
+ 4. **For Receive:**
89
+ - Paste the share key from the sender
90
+ - Files save to current directory by default
91
+
92
+ ### Direct CLI Mode
93
+
94
+ Or use direct commands for quick transfers:
95
+
96
+ #### Send a file or directory
97
+
98
+ ```bash
99
+ ezshare send <path>
100
+ ```
101
+
102
+ Example:
103
+ ```bash
104
+ $ ezshare send ./documents
105
+
106
+ 📤 Sending: documents
107
+ Size: 2.4 MB | Files: 3
108
+
109
+ Share this key with receiver:
110
+ BhhzKS7G4iIq4okKNYhaoqljeLMAjMR8UkpaLyqP7EA
111
+
112
+ ⠋ Waiting for peer to connect...
113
+ ```
114
+
115
+ #### Receive a file
116
+
117
+ ```bash
118
+ ezshare receive <share-key> [--output <directory>]
119
+ ```
120
+
121
+ Example:
122
+ ```bash
123
+ $ ezshare receive BhhzKS7G4iIq4okKNYhaoqljeLMAjMR8UkpaLyqP7EA
124
+
125
+ 📥 Receiving file(s)
126
+ Size: 2.4 MB | Files: 3
127
+
128
+ ⠋ Connecting to peer...
129
+ ████████████████████████████████████████ 100%
130
+
131
+ ✓ Transfer complete!
132
+ Saved to: /current/directory
133
+ ```
134
+
135
+ Specify output directory:
136
+ ```bash
137
+ ezshare receive <key> --output ~/Downloads
138
+ ```
139
+
140
+ ## 🔧 How It Works
141
+
142
+ ```
143
+ ┌─────────────┐ ┌─────────────┐
144
+ │ Sender │ │ Receiver │
145
+ └──────┬──────┘ └──────┬──────┘
146
+ │ │
147
+ │ 1. Generate random topic key │
148
+ │ 2. Display share key to user │
149
+ │ │
150
+ │ 3. Announce to Hyperswarm DHT ────────────────────►│ 4. Look up in DHT
151
+ │ │
152
+ │◄───────────────── 5. P2P Connection ──────────────┤
153
+ │ (NAT hole-punching) │
154
+ │ │
155
+ │ Files → Tar → Compress* → Encrypt (AES-256) │
156
+ │ │
157
+ ├──────────────────► Transfer ──────────────────────┤
158
+ │ │
159
+ │ Decrypt → Decompress → Extract → Files
160
+ │ │
161
+ │◄───────────────── 6. Close Connection ────────────┤
162
+ ```
163
+
164
+ *Compression is skipped for already-compressed formats (`.zip`, `.gz`, `.mp4`, `.jpg`, etc.)
165
+
166
+ ## 🛡️ Security
167
+
168
+ - **Key Generation**: Random 32-byte topic key generated per transfer
169
+ - **Key Derivation**: AES key derived from topic using HKDF-SHA256
170
+ - **Encryption**: AES-256-GCM with unique nonces per 64KB chunk
171
+ - **Authentication**: GCM mode provides integrity verification
172
+ - **Transport**: Hyperswarm uses Noise protocol for transport encryption
173
+
174
+ ⚠️ **Important**: The share key is the only secret. Anyone with the key can receive the file. Share it securely (Signal, encrypted email, etc.).
175
+
176
+ ## 🏗️ Architecture
177
+
178
+ ### Tech Stack
179
+
180
+ | Component | Technology |
181
+ |-----------|------------|
182
+ | Runtime | Node.js 18+ with TypeScript ES Modules |
183
+ | P2P Network | [Hyperswarm](https://github.com/holepunchto/hyperswarm) (Kademlia DHT + NAT traversal) |
184
+ | Encryption | AES-256-GCM (Node.js crypto) |
185
+ | Compression | [Zstandard](https://github.com/facebook/zstd) via simple-zstd |
186
+ | Archiving | tar-stream (streaming tar) |
187
+ | TUI | [Ink](https://github.com/vadimdemedes/ink) (React for CLI) |
188
+ | CLI Parser | meow |
189
+
190
+ ### Project Structure
191
+
192
+ ```
193
+ ezsharecli/
194
+ ├── bin/
195
+ │ └── ezshare.js # Global CLI entry point
196
+ ├── src/
197
+ │ ├── cli.tsx # Main CLI router (interactive vs direct mode)
198
+ │ ├── components/
199
+ │ │ ├── Shell.tsx # Interactive shell REPL
200
+ │ │ ├── MainMenu.tsx # Send/Receive menu
201
+ │ │ ├── FileBrowser.tsx # Arrow-key file navigator
202
+ │ │ ├── HelpScreen.tsx # Help documentation
203
+ │ │ └── TransferUI.tsx # Transfer progress UI
204
+ │ ├── commands/
205
+ │ │ ├── send.tsx # Send command implementation
206
+ │ │ └── receive.tsx # Receive command implementation
207
+ │ └── utils/
208
+ │ ├── crypto.ts # AES-256-GCM encryption/decryption streams
209
+ │ ├── compression.ts # Zstd compression with format detection
210
+ │ ├── network.ts # Hyperswarm connection management
211
+ │ ├── tar.ts # Tar pack/extract utilities
212
+ │ └── fileSystem.ts # File browser utilities
213
+ ├── package.json
214
+ └── tsconfig.json
215
+ ```
216
+
217
+ ## 🐛 Troubleshooting
218
+
219
+ ### Connection Issues
220
+
221
+ **"Connection timeout after 30s"**
222
+ - Ensure both sender and receiver are started within ~10 seconds
223
+ - Check firewall settings (Hyperswarm needs UDP for DHT)
224
+ - Try on different networks if behind restrictive NAT
225
+
226
+ **"Could not find or connect to sender"**
227
+ - Verify the share key is correct (copy-paste to avoid typos)
228
+ - Ensure sender is still running and waiting
229
+ - Both peers need internet connectivity for DHT bootstrap
230
+
231
+ ### Compression Issues
232
+
233
+ **"zstd: command not found"**
234
+ - Install zstd using your package manager (see Installation section)
235
+
236
+ ### Performance Tips
237
+
238
+ - Large files (>1GB): Transfers work fine, but both peers should have stable connections
239
+ - Firewalls: Allow UDP traffic for best DHT performance
240
+ - Multiple files: Directory transfers are automatically tar-packed
241
+
242
+ ## 💡 Examples
243
+
244
+ ### Send a single file
245
+ ```bash
246
+ ezshare send presentation.pdf
247
+ ```
248
+
249
+ ### Send a directory
250
+ ```bash
251
+ ezshare send ./project-folder
252
+ ```
253
+
254
+ ### Receive to specific location
255
+ ```bash
256
+ ezshare receive ABC123XYZ --output ~/Downloads
257
+ ```
258
+
259
+ ### Interactive mode with file browser
260
+ ```bash
261
+ ezshare
262
+ # Then: /ezshare → Send → Navigate with arrows → Select file
263
+ ```
264
+
265
+ ## 🔮 Roadmap
266
+
267
+ - [ ] Resume interrupted transfers
268
+ - [ ] Multiple simultaneous receivers
269
+ - [ ] Transfer speed indicator and ETA
270
+ - [ ] QR code for share keys (mobile)
271
+ - [ ] Custom encryption passphrases
272
+ - [ ] Web UI companion app
273
+ - [ ] Transfer history
274
+
275
+ ## 🤝 Contributing
276
+
277
+ Contributions are welcome! Here's how:
278
+
279
+ ```bash
280
+ # Clone the repository
281
+ git clone https://github.com/yourusername/ezsharecli.git
282
+ cd ezsharecli
283
+
284
+ # Install dependencies
285
+ npm install
286
+
287
+ # Run in development
288
+ npm run dev
289
+
290
+ # Build
291
+ npm run build
292
+
293
+ # Run tests
294
+ npm test
295
+ ```
296
+
297
+ Please open an issue before starting major features.
298
+
299
+ ## 📝 License
300
+
301
+ MIT License - see [LICENSE](LICENSE) for details.
302
+
303
+ ## 🙏 Acknowledgments
304
+
305
+ - Built with [Hyperswarm](https://github.com/holepunchto/hyperswarm) by Holepunch
306
+ - Inspired by [Magic Wormhole](https://magic-wormhole.readthedocs.io/)
307
+ - TUI powered by [Ink](https://github.com/vadimdemedes/ink)
308
+
309
+ ---
310
+
311
+ **Made with ❤️ for secure, decentralized file sharing**
package/bin/ezshare.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/cli.js';
package/dist/cli.js ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import meow from 'meow';
4
+ import { render } from 'ink';
5
+ import { Shell } from './components/Shell.js';
6
+ import { SendCommand } from './commands/send.js';
7
+ import { ReceiveCommand } from './commands/receive.js';
8
+ const cli = meow(`
9
+ Usage
10
+ $ ezshare # Interactive shell mode
11
+ $ ezshare send <path> # Direct send
12
+ $ ezshare receive <key> [--output] # Direct receive
13
+
14
+ Examples
15
+ $ ezshare # Launch interactive shell
16
+ $ ezshare send ./myfile.zip # Quick send
17
+ $ ezshare receive abc123... -o ./downloads
18
+
19
+ Options
20
+ --output, -o Output directory for received files (default: current directory)
21
+
22
+ Interactive Mode:
23
+ Launch with no arguments to enter interactive shell mode.
24
+ Use slash commands:
25
+ /ezshare - Start file transfer
26
+ /help - Show help
27
+ /exit - Exit shell
28
+ `, {
29
+ importMeta: import.meta,
30
+ flags: {
31
+ output: { type: 'string', shortFlag: 'o' }
32
+ }
33
+ });
34
+ const [command, arg] = cli.input;
35
+ // Interactive shell mode (no arguments)
36
+ if (!command) {
37
+ render(_jsx(Shell, {}));
38
+ }
39
+ // Direct CLI mode - Send
40
+ else if (command === 'send') {
41
+ if (!arg) {
42
+ console.error('Error: Please specify a file or directory to send');
43
+ console.log('Usage: ezshare send <path>');
44
+ process.exit(1);
45
+ }
46
+ render(_jsx(SendCommand, { path: arg, onComplete: () => process.exit(0), onError: () => process.exit(1) }));
47
+ }
48
+ // Direct CLI mode - Receive
49
+ else if (command === 'receive') {
50
+ if (!arg) {
51
+ console.error('Error: Please specify a share key');
52
+ console.log('Usage: ezshare receive <key> [--output <dir>]');
53
+ process.exit(1);
54
+ }
55
+ const outputPath = cli.flags.output || process.cwd();
56
+ render(_jsx(ReceiveCommand, { shareKey: arg, outputPath: outputPath, onComplete: () => process.exit(0), onError: () => process.exit(1) }));
57
+ }
58
+ else {
59
+ cli.showHelp();
60
+ }
@@ -0,0 +1,129 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { Spinner, ProgressBar } from '@inkjs/ui';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { Transform } from 'node:stream';
7
+ import { parseTopicKey, deriveKey, createDecryptStream } from '../utils/crypto.js';
8
+ import { createDecompressStream } from '../utils/compression.js';
9
+ import { createExtractStream } from '../utils/tar.js';
10
+ import { createReceiverSwarm, cleanupSwarm } from '../utils/network.js';
11
+ export function ReceiveCommand({ shareKey, outputPath = process.cwd(), onComplete, onError }) {
12
+ const [state, setState] = useState('connecting');
13
+ const [progress, setProgress] = useState(0);
14
+ const [error, setError] = useState(null);
15
+ const [metadata, setMetadata] = useState(null);
16
+ useEffect(() => {
17
+ startReceiving();
18
+ }, []);
19
+ const startReceiving = async () => {
20
+ try {
21
+ // Parse share key to get topic
22
+ const topic = parseTopicKey(shareKey);
23
+ // Derive encryption key from topic
24
+ const encryptionKey = deriveKey(topic);
25
+ // Create receiver swarm and get connection promise
26
+ const { swarm, connectionPromise } = await createReceiverSwarm(topic);
27
+ // Wait for peer connection (listener already registered)
28
+ const socket = await connectionPromise;
29
+ console.log('[Receiver] Connected to sender');
30
+ // Add socket error handler
31
+ socket.on('error', (err) => {
32
+ console.error('[Receiver] Socket error:', err);
33
+ });
34
+ socket.on('end', () => {
35
+ console.log('[Receiver] Socket end event received');
36
+ });
37
+ socket.on('close', () => {
38
+ console.log('[Receiver] Socket closed');
39
+ });
40
+ // Read metadata first (JSON header followed by newline)
41
+ const metadataBuffer = [];
42
+ let metadataReceived = false;
43
+ let transferMetadata = null;
44
+ // Create a passthrough to split metadata from file data
45
+ const metadataExtractor = new Transform({
46
+ transform(chunk, encoding, callback) {
47
+ if (!metadataReceived) {
48
+ metadataBuffer.push(chunk);
49
+ const combined = Buffer.concat(metadataBuffer);
50
+ const newlineIndex = combined.indexOf('\n');
51
+ if (newlineIndex !== -1) {
52
+ // Found metadata
53
+ try {
54
+ const metadataJson = combined.slice(0, newlineIndex).toString();
55
+ console.log('[Receiver] Received metadata:', metadataJson);
56
+ transferMetadata = JSON.parse(metadataJson);
57
+ setMetadata(transferMetadata);
58
+ // Push remaining data after newline
59
+ const remainingData = combined.slice(newlineIndex + 1);
60
+ console.log(`[Receiver] Metadata extracted, ${remainingData.length} bytes of data after newline`);
61
+ if (remainingData.length > 0) {
62
+ this.push(remainingData);
63
+ }
64
+ metadataReceived = true;
65
+ }
66
+ catch (err) {
67
+ console.error('[Receiver] Failed to parse metadata:', err);
68
+ callback(err);
69
+ return;
70
+ }
71
+ }
72
+ callback();
73
+ }
74
+ else {
75
+ // Pass through file data
76
+ callback(null, chunk);
77
+ }
78
+ },
79
+ });
80
+ // Create progress tracker
81
+ let transferred = 0;
82
+ const progressTracker = new Transform({
83
+ transform(chunk, encoding, callback) {
84
+ if (transferMetadata) {
85
+ transferred += chunk.length;
86
+ const pct = Math.round((transferred / transferMetadata.totalSize) * 100);
87
+ setProgress(Math.min(pct, 100));
88
+ }
89
+ callback(null, chunk);
90
+ },
91
+ });
92
+ // Start receiving
93
+ setState('receiving');
94
+ console.log('[Receiver] Starting receive pipeline');
95
+ // Build the pipeline: Socket → Metadata Extractor → Progress → Decrypt → Decompress → Tar Extract
96
+ const decryptStream = createDecryptStream(encryptionKey);
97
+ const decompressStream = await createDecompressStream();
98
+ const extractStream = createExtractStream(outputPath);
99
+ await pipeline(socket, metadataExtractor, progressTracker, decryptStream, decompressStream, extractStream);
100
+ console.log('[Receiver] Pipeline completed successfully');
101
+ // Transfer complete
102
+ setState('done');
103
+ // Receiver cleanup - small delay to ensure socket is fully closed
104
+ await new Promise(resolve => setTimeout(resolve, 100));
105
+ console.log('[Receiver] Cleaning up receiver swarm');
106
+ await cleanupSwarm(swarm);
107
+ if (onComplete) {
108
+ onComplete();
109
+ }
110
+ }
111
+ catch (err) {
112
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
113
+ setError(errorMessage);
114
+ setState('error');
115
+ if (onError) {
116
+ onError(err);
117
+ }
118
+ }
119
+ };
120
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "blue", children: "\uD83D\uDCE5 Receiving file(s)" }), metadata && (_jsxs(Text, { dimColor: true, children: ["Size: ", formatBytes(metadata.totalSize), " | Files: ", metadata.fileCount] })), state === 'connecting' && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Connecting to peer..." }) })), state === 'receiving' && progress === 0 && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Starting transfer..." }) })), state === 'receiving' && progress > 0 && progress < 100 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(ProgressBar, { value: progress }), _jsxs(Text, { children: [" ", progress, "%"] })] })), state === 'done' && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Transfer complete!" }), _jsxs(Text, { dimColor: true, children: ["Saved to: ", outputPath] }), _jsx(Text, { dimColor: true, children: "Press Esc to return" })] })), state === 'error' && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "red", bold: true, children: "\u2717 Transfer failed" }), _jsx(Text, { color: "red", children: error }), _jsx(Text, { dimColor: true, children: "Press Esc to return" })] }))] }));
121
+ }
122
+ function formatBytes(bytes) {
123
+ if (bytes === 0)
124
+ return '0 B';
125
+ const k = 1024;
126
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
127
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
128
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
129
+ }
@@ -0,0 +1,117 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState, useEffect } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { Spinner, ProgressBar } from '@inkjs/ui';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { Transform } from 'node:stream';
7
+ import { generateTopicKey, deriveKey, createEncryptStream } from '../utils/crypto.js';
8
+ import { createCompressStream, shouldCompress } from '../utils/compression.js';
9
+ import { createPackStream, getTransferMetadata } from '../utils/tar.js';
10
+ import { createSenderSwarm, cleanupSwarm } from '../utils/network.js';
11
+ export function SendCommand({ path, onComplete, onError }) {
12
+ const [state, setState] = useState('init');
13
+ const [shareKey, setShareKey] = useState('');
14
+ const [progress, setProgress] = useState(0);
15
+ const [error, setError] = useState(null);
16
+ const [metadata, setMetadata] = useState(null);
17
+ useEffect(() => {
18
+ startSending();
19
+ }, []);
20
+ const startSending = async () => {
21
+ try {
22
+ // Generate topic and display key
23
+ const { topic, displayKey } = generateTopicKey();
24
+ setShareKey(displayKey);
25
+ // Derive encryption key from topic
26
+ const encryptionKey = deriveKey(topic);
27
+ // Get transfer metadata
28
+ const transferMetadata = await getTransferMetadata(path);
29
+ setMetadata({
30
+ totalSize: transferMetadata.totalSize,
31
+ fileCount: transferMetadata.fileCount,
32
+ });
33
+ // Create sender swarm
34
+ setState('waiting');
35
+ const { swarm, waitForPeer } = await createSenderSwarm(topic);
36
+ // Wait for peer connection
37
+ const socket = await waitForPeer();
38
+ // Register socket close listener BEFORE pipeline starts
39
+ // This ensures we don't miss the close event
40
+ const socketClosed = new Promise((resolve) => {
41
+ socket.once('close', () => {
42
+ console.log('[Sender] Socket closed');
43
+ resolve();
44
+ });
45
+ });
46
+ // Add socket error handler
47
+ socket.on('error', (err) => {
48
+ console.error('[Sender] Socket error:', err);
49
+ });
50
+ // Peer connected! Start sending
51
+ setState('sending');
52
+ console.log('[Sender] Starting transfer pipeline');
53
+ // Create metadata prepend stream
54
+ const metadataJson = JSON.stringify({
55
+ totalSize: transferMetadata.totalSize,
56
+ fileCount: transferMetadata.fileCount,
57
+ isDirectory: transferMetadata.isDirectory,
58
+ compressed: shouldCompress(path),
59
+ }) + '\n';
60
+ let metadataSent = false;
61
+ const metadataPrepender = new Transform({
62
+ transform(chunk, encoding, callback) {
63
+ if (!metadataSent) {
64
+ // Send metadata as first chunk
65
+ this.push(Buffer.from(metadataJson));
66
+ metadataSent = true;
67
+ }
68
+ callback(null, chunk);
69
+ },
70
+ });
71
+ // Create progress tracker
72
+ let transferred = 0;
73
+ const progressTracker = new Transform({
74
+ transform(chunk, encoding, callback) {
75
+ transferred += chunk.length;
76
+ const pct = Math.round((transferred / transferMetadata.totalSize) * 100);
77
+ setProgress(pct);
78
+ callback(null, chunk);
79
+ },
80
+ });
81
+ // Build the pipeline: Tar → Compress → Encrypt → Metadata → Progress → Socket
82
+ // Note: Metadata is added AFTER encryption so it's sent in plaintext
83
+ const packStream = createPackStream(path);
84
+ const compressStream = await createCompressStream(shouldCompress(path));
85
+ const encryptStream = createEncryptStream(encryptionKey);
86
+ await pipeline(packStream, compressStream, encryptStream, metadataPrepender, progressTracker, socket);
87
+ console.log('[Sender] Pipeline completed, waiting for socket to close');
88
+ // Wait for socket to fully close before cleaning up
89
+ // This ensures the receiver has finished and closed their end
90
+ await socketClosed;
91
+ console.log('[Sender] Socket closed, cleaning up swarm');
92
+ // Transfer complete
93
+ setState('done');
94
+ await cleanupSwarm(swarm);
95
+ if (onComplete) {
96
+ onComplete();
97
+ }
98
+ }
99
+ catch (err) {
100
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
101
+ setError(errorMessage);
102
+ setState('error');
103
+ if (onError) {
104
+ onError(err);
105
+ }
106
+ }
107
+ };
108
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { bold: true, color: "green", children: ["\uD83D\uDCE4 Sending: ", path.split('/').pop()] }), metadata && (_jsxs(Text, { dimColor: true, children: ["Size: ", formatBytes(metadata.totalSize), " | Files: ", metadata.fileCount] })), state === 'init' && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Preparing transfer..." }) })), (state === 'waiting' || state === 'sending') && shareKey && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: "Share this key with receiver:" }), _jsx(Text, { bold: true, children: shareKey })] })), state === 'waiting' && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Waiting for peer to connect..." }) })), state === 'sending' && progress > 0 && progress < 100 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(ProgressBar, { value: progress }), _jsxs(Text, { children: [" ", progress, "%"] })] })), state === 'done' && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Transfer complete!" }), _jsx(Text, { dimColor: true, children: "Press Esc to return" })] })), state === 'error' && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "red", bold: true, children: "\u2717 Transfer failed" }), _jsx(Text, { color: "red", children: error }), _jsx(Text, { dimColor: true, children: "Press Esc to return" })] }))] }));
109
+ }
110
+ function formatBytes(bytes) {
111
+ if (bytes === 0)
112
+ return '0 B';
113
+ const k = 1024;
114
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
115
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
116
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
117
+ }