@drop2p/cli 0.1.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/dist/cli.js ADDED
@@ -0,0 +1,1248 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import qrcode from "qrcode-terminal";
5
+
6
+ // ../../packages/protocol/src/index.ts
7
+ var DSTP_VERSION = "DSTP/1";
8
+ var CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
9
+ var DEFAULTS = {
10
+ /** Routing portion of a transfer code, e.g. the "7K" in "7K-Q9MZ4P". */
11
+ channelLength: 2,
12
+ /** Secret portion (the PAKE password) — never sent to the server. */
13
+ secretLength: 6,
14
+ /** On-wire encrypted frame size (plaintext bytes before AEAD). */
15
+ frameBytes: 64 * 1024
16
+ };
17
+ var GiB = 1024 ** 3;
18
+ var MINUTE = 60 * 1e3;
19
+ var HOUR = 60 * MINUTE;
20
+ var PLAN_LIMITS = {
21
+ free: { plan: "free", maxFileBytes: 1 * GiB, maxConcurrent: 1, monthlyTransfers: null, sessionTtlMs: 30 * MINUTE },
22
+ pro: { plan: "pro", maxFileBytes: 10 * GiB, maxConcurrent: 2, monthlyTransfers: 20, sessionTtlMs: 24 * HOUR },
23
+ ultimate: { plan: "ultimate", maxFileBytes: 50 * GiB, maxConcurrent: 5, monthlyTransfers: null, sessionTtlMs: 72 * HOUR }
24
+ };
25
+
26
+ // ../../packages/transfer-core/src/code.ts
27
+ function generateSecret(length = DEFAULTS.secretLength) {
28
+ const bytes = new Uint8Array(length);
29
+ crypto.getRandomValues(bytes);
30
+ let out3 = "";
31
+ for (const b of bytes) out3 += CODE_ALPHABET.charAt(b % CODE_ALPHABET.length);
32
+ return out3;
33
+ }
34
+ function formatCode(channel, secret) {
35
+ return `${channel}-${secret}`;
36
+ }
37
+ function splitCode(code) {
38
+ const cleaned = code.trim().toUpperCase().replace(/\s+/g, "");
39
+ const [channel, secret] = cleaned.split("-");
40
+ return { channel: channel ?? "", secret: secret ?? "" };
41
+ }
42
+
43
+ // ../../packages/transfer-core/src/dstp.ts
44
+ var TEXT = new TextEncoder();
45
+ var bs = (v) => v;
46
+ async function createPake(mod, secret, channel) {
47
+ const session = new mod.Spake2Session(
48
+ TEXT.encode(secret.toUpperCase()),
49
+ TEXT.encode(`DSTP/1:${channel}`)
50
+ );
51
+ const outbound = session.outboundMessage();
52
+ return { outbound, finish: (inbound) => session.finish(inbound) };
53
+ }
54
+ async function deriveFileKey(master, salt) {
55
+ const ikm = await crypto.subtle.importKey("raw", bs(master), "HKDF", false, ["deriveKey"]);
56
+ return crypto.subtle.deriveKey(
57
+ { name: "HKDF", hash: "SHA-256", salt: bs(salt), info: bs(TEXT.encode("DSTP/1 file")) },
58
+ ikm,
59
+ { name: "AES-GCM", length: 256 },
60
+ false,
61
+ ["encrypt", "decrypt"]
62
+ );
63
+ }
64
+ var VAULT_SALT_BYTES = 16;
65
+ var VAULT_VERIFIER_COUNTER = 4294967295;
66
+ var VAULT_MARKER = TEXT.encode("DSTP/1 vault ok");
67
+ function randomSalt() {
68
+ const salt = new Uint8Array(VAULT_SALT_BYTES);
69
+ crypto.getRandomValues(salt);
70
+ return salt;
71
+ }
72
+ async function deriveVaultKey(mod, passphrase, salt) {
73
+ const raw = mod.argon2idDerive(TEXT.encode(passphrase), salt);
74
+ return crypto.subtle.importKey("raw", bs(raw), { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
75
+ }
76
+ async function makeVaultVerifier(vaultKey) {
77
+ return new Uint8Array(await encryptFrame(vaultKey, VAULT_VERIFIER_COUNTER, VAULT_MARKER));
78
+ }
79
+ async function checkVaultVerifier(vaultKey, verifier) {
80
+ try {
81
+ const out3 = new Uint8Array(await decryptFrame(vaultKey, VAULT_VERIFIER_COUNTER, bs(verifier)));
82
+ return out3.length === VAULT_MARKER.length && out3.every((b, i) => b === VAULT_MARKER[i]);
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+ async function sha256(...parts) {
88
+ const total = parts.reduce((n, p) => n + p.length, 0);
89
+ const buf = new Uint8Array(total);
90
+ let offset = 0;
91
+ for (const p of parts) {
92
+ buf.set(p, offset);
93
+ offset += p.length;
94
+ }
95
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", bs(buf)));
96
+ }
97
+ function nonce(counter) {
98
+ const n = new Uint8Array(12);
99
+ new DataView(n.buffer).setBigUint64(4, BigInt(counter));
100
+ return n;
101
+ }
102
+ async function encryptFrame(key, counter, plaintext) {
103
+ const iv = nonce(counter);
104
+ const additionalData = new Uint8Array(iv.subarray(4));
105
+ return crypto.subtle.encrypt(
106
+ { name: "AES-GCM", iv: bs(iv), additionalData: bs(additionalData), tagLength: 128 },
107
+ key,
108
+ bs(plaintext)
109
+ );
110
+ }
111
+ async function decryptFrame(key, counter, ciphertext) {
112
+ const iv = nonce(counter);
113
+ const additionalData = new Uint8Array(iv.subarray(4));
114
+ return crypto.subtle.decrypt(
115
+ { name: "AES-GCM", iv: bs(iv), additionalData: bs(additionalData), tagLength: 128 },
116
+ key,
117
+ bs(ciphertext)
118
+ );
119
+ }
120
+ function toB64(u8) {
121
+ let s = "";
122
+ for (const b of u8) s += String.fromCharCode(b);
123
+ return btoa(s);
124
+ }
125
+ function fromB64(s) {
126
+ const bin = atob(s);
127
+ const u8 = new Uint8Array(bin.length);
128
+ for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
129
+ return u8;
130
+ }
131
+
132
+ // ../../packages/transfer-core/src/manager.ts
133
+ var MAX_CHUNK_BYTES = 256 * 1024;
134
+ var FRAME_OVERHEAD = 64;
135
+ var HIGH_WATER = 2 * 1024 * 1024;
136
+ var LOW_WATER = 1 * 1024 * 1024;
137
+ var PROGRESS_INTERVAL_MS = 100;
138
+ var TYPE_META = 1;
139
+ var TYPE_CHUNK = 2;
140
+ var TYPE_DONE = 3;
141
+ var TEXT_ENC = new TextEncoder();
142
+ var TEXT_DEC = new TextDecoder();
143
+ var now = () => typeof performance !== "undefined" ? performance.now() : Date.now();
144
+ var TransferManager = class {
145
+ constructor(env, update) {
146
+ this.env = env;
147
+ this.update = update;
148
+ this.sig = env.createSignaling(env.signalingUrl);
149
+ }
150
+ sig;
151
+ pc;
152
+ dc;
153
+ iceServers = [];
154
+ role = "sender";
155
+ channel = "";
156
+ secret = "";
157
+ phase = "idle";
158
+ remoteReady = false;
159
+ pendingCandidates = [];
160
+ source;
161
+ crypto;
162
+ // crypto / handshake
163
+ pake;
164
+ peerPake;
165
+ fileKey;
166
+ sendCounter = 0;
167
+ recvCounter = 0;
168
+ // Ultimate "double encryption": optional passphrase-derived (Argon2id) layer
169
+ // applied to file content *inside* the DSTP file key.
170
+ passphrase;
171
+ vaultKey;
172
+ vaultSalt;
173
+ vaultVerifier;
174
+ innerSendCounter = 0;
175
+ innerRecvCounter = 0;
176
+ keyReadyResolve;
177
+ keyReady = new Promise((r) => this.keyReadyResolve = r);
178
+ recvChain = Promise.resolve();
179
+ // sender waits for the receiver's sink to be ready before sending file bytes
180
+ peerReadyResolve;
181
+ peerReady = new Promise((r) => this.peerReadyResolve = r);
182
+ // sender waits for the receiver to confirm full receipt before tearing down,
183
+ // so a fast-exiting peer (e.g. the CLI) can't close the channel mid-delivery.
184
+ peerDoneResolve;
185
+ /** Resolves when the receiver has confirmed it got the whole file. */
186
+ peerDone = new Promise((r) => this.peerDoneResolve = r);
187
+ // progress throttling + speed sampling
188
+ transferStart = 0;
189
+ lastEmit = 0;
190
+ lastEmitBytes = 0;
191
+ // receiver-side sink
192
+ sink;
193
+ recvMeta;
194
+ recvBytes = 0;
195
+ closed = false;
196
+ sentAllData = false;
197
+ completed = false;
198
+ // ---------- public API ----------
199
+ async sendFile(source, opts) {
200
+ this.role = "sender";
201
+ this.source = source;
202
+ this.passphrase = opts?.passphrase?.trim() || void 0;
203
+ this.patch({
204
+ phase: "connecting",
205
+ role: "sender",
206
+ fileName: source.name,
207
+ fileSize: source.size,
208
+ transferred: 0,
209
+ progress: 0,
210
+ protected: Boolean(this.passphrase)
211
+ });
212
+ if (!await this.openSignalling()) return;
213
+ this.send({ t: "hello", v: DSTP_VERSION, role: "sender", authToken: this.authToken() });
214
+ }
215
+ async receive(rawCode) {
216
+ const { channel, secret } = splitCode(rawCode);
217
+ if (!channel || !secret) {
218
+ this.fail("That code doesn't look right \u2014 it should look like 8QVY-Q9MZ4P.");
219
+ return;
220
+ }
221
+ this.role = "receiver";
222
+ this.channel = channel;
223
+ this.secret = secret;
224
+ this.patch({ phase: "connecting", role: "receiver" });
225
+ if (!await this.openSignalling()) return;
226
+ this.send({ t: "hello", v: DSTP_VERSION, role: "receiver", authToken: this.authToken() });
227
+ }
228
+ /** Receiver: user entered the passphrase for a protected (double-encrypted) transfer. */
229
+ async submitPassphrase(passphrase) {
230
+ const pass = passphrase.trim();
231
+ if (!this.recvMeta || !this.vaultSalt || !this.vaultVerifier || !pass) return;
232
+ let key;
233
+ try {
234
+ const mod = await this.loadCrypto();
235
+ key = await deriveVaultKey(mod, pass, this.vaultSalt);
236
+ } catch {
237
+ this.patch({ error: "Couldn't process that passphrase. Try again." });
238
+ return;
239
+ }
240
+ if (!await checkVaultVerifier(key, this.vaultVerifier)) {
241
+ this.patch({ error: "That passphrase doesn't match. Try again." });
242
+ return;
243
+ }
244
+ this.vaultKey = key;
245
+ this.patch({ error: void 0 });
246
+ await this.prepareSink(this.recvMeta);
247
+ }
248
+ /** Receiver: user clicked "Save" — open the deferred sink (a user gesture) and go. */
249
+ async confirmSave() {
250
+ if (!this.recvMeta) return;
251
+ try {
252
+ this.sink = await this.env.createDeferredSink(this.recvMeta);
253
+ } catch {
254
+ this.fail("Save cancelled \u2014 pick a location to receive the file.");
255
+ return;
256
+ }
257
+ this.sendReady();
258
+ this.startProgress();
259
+ this.patch({ phase: "transferring", transferred: 0, progress: 0 });
260
+ }
261
+ close() {
262
+ this.closed = true;
263
+ try {
264
+ this.send({ t: "bye" });
265
+ } catch {
266
+ }
267
+ void this.sink?.abort();
268
+ this.dc?.close();
269
+ this.pc?.close();
270
+ this.sig.close();
271
+ }
272
+ // ---------- signalling ----------
273
+ authToken() {
274
+ return this.env.getAuthToken?.() ?? void 0;
275
+ }
276
+ async loadCrypto() {
277
+ if (!this.crypto) this.crypto = await this.env.loadCrypto();
278
+ return this.crypto;
279
+ }
280
+ async openSignalling() {
281
+ try {
282
+ await this.sig.connect();
283
+ } catch {
284
+ this.fail("Couldn't reach the server. Is the signalling server running?");
285
+ return false;
286
+ }
287
+ this.sig.onMessage = (m) => this.onServer(m);
288
+ return true;
289
+ }
290
+ send(msg) {
291
+ this.sig.send(msg);
292
+ }
293
+ onServer(m) {
294
+ switch (m.t) {
295
+ case "hello_ack":
296
+ this.iceServers = m.iceServers;
297
+ if (this.role === "sender") this.send({ t: "create", fileSize: this.source?.size ?? 0 });
298
+ else this.send({ t: "join", channel: this.channel });
299
+ return;
300
+ case "created": {
301
+ this.channel = m.channel;
302
+ this.secret = generateSecret();
303
+ this.setupPeer();
304
+ this.patch({
305
+ phase: "awaiting-peer",
306
+ code: formatCode(m.channel, this.secret),
307
+ expiresAt: m.expiresAt
308
+ });
309
+ return;
310
+ }
311
+ case "joined":
312
+ this.setupPeer();
313
+ return;
314
+ case "peer_joined":
315
+ void this.makeOffer();
316
+ return;
317
+ case "peer_left":
318
+ if (this.completed || this.phase === "done") return;
319
+ if (this.role === "sender" && this.sentAllData) {
320
+ this.finishOk(this.source?.size ?? 0);
321
+ return;
322
+ }
323
+ this.fail("The other side disconnected.");
324
+ return;
325
+ case "signal":
326
+ void this.onSignal(m.type, m.payload);
327
+ return;
328
+ case "error":
329
+ this.fail(this.errorText(m.code));
330
+ return;
331
+ case "pong":
332
+ return;
333
+ }
334
+ }
335
+ // ---------- WebRTC ----------
336
+ setupPeer() {
337
+ if (this.pc) return;
338
+ const pc = this.env.createPeerConnection(this.iceServers);
339
+ this.pc = pc;
340
+ pc.onicecandidate = (e) => {
341
+ if (e.candidate) this.send({ t: "signal", type: "candidate", payload: e.candidate.toJSON() });
342
+ };
343
+ pc.onconnectionstatechange = () => {
344
+ if (pc.connectionState === "connected") void this.detectConnectionType();
345
+ else if (pc.connectionState === "failed" && !this.completed && this.phase !== "done") {
346
+ this.fail(
347
+ "Couldn't connect \u2014 a strict network or firewall blocked a direct link between the two devices. If a relay is configured it'll retry through that; otherwise try both devices on the same Wi-Fi."
348
+ );
349
+ }
350
+ };
351
+ if (this.role === "sender") {
352
+ this.setupDataChannel(pc.createDataChannel("drop2p", { ordered: true }));
353
+ } else {
354
+ pc.ondatachannel = (e) => this.setupDataChannel(e.channel);
355
+ }
356
+ }
357
+ async makeOffer() {
358
+ if (!this.pc) return;
359
+ const offer = await this.pc.createOffer();
360
+ await this.pc.setLocalDescription(offer);
361
+ this.send({ t: "signal", type: "offer", payload: offer });
362
+ }
363
+ async onSignal(type, payload) {
364
+ if (!this.pc) return;
365
+ if (type === "offer") {
366
+ await this.pc.setRemoteDescription(payload);
367
+ this.remoteReady = true;
368
+ await this.flushCandidates();
369
+ const answer = await this.pc.createAnswer();
370
+ await this.pc.setLocalDescription(answer);
371
+ this.send({ t: "signal", type: "answer", payload: answer });
372
+ } else if (type === "answer") {
373
+ await this.pc.setRemoteDescription(payload);
374
+ this.remoteReady = true;
375
+ await this.flushCandidates();
376
+ } else if (type === "candidate") {
377
+ const cand = payload;
378
+ if (this.remoteReady) await this.pc.addIceCandidate(cand).catch(() => {
379
+ });
380
+ else this.pendingCandidates.push(cand);
381
+ }
382
+ }
383
+ async flushCandidates() {
384
+ for (const c of this.pendingCandidates) await this.pc?.addIceCandidate(c).catch(() => {
385
+ });
386
+ this.pendingCandidates = [];
387
+ }
388
+ // ---------- DataChannel: handshake + encrypted IO ----------
389
+ setupDataChannel(dc) {
390
+ this.dc = dc;
391
+ dc.binaryType = "arraybuffer";
392
+ dc.bufferedAmountLowThreshold = LOW_WATER;
393
+ dc.onmessage = (e) => {
394
+ const data = e.data;
395
+ this.recvChain = this.recvChain.then(() => this.handleData(data)).catch(() => {
396
+ });
397
+ };
398
+ dc.onopen = () => {
399
+ this.patch({ phase: "connected" });
400
+ void this.startHandshake();
401
+ };
402
+ }
403
+ async startHandshake() {
404
+ try {
405
+ const mod = await this.loadCrypto();
406
+ this.pake = await createPake(mod, this.secret, this.channel);
407
+ if (this.dc?.readyState === "open") {
408
+ this.dc.send(JSON.stringify({ kind: "pake", data: toB64(this.pake.outbound) }));
409
+ }
410
+ await this.maybeCompleteHandshake();
411
+ } catch {
412
+ this.fail("Couldn't start the secure handshake.");
413
+ }
414
+ }
415
+ async maybeCompleteHandshake() {
416
+ if (this.fileKey || !this.pake || !this.peerPake) return;
417
+ const master = this.pake.finish(this.peerPake);
418
+ const senderMsg = this.role === "sender" ? this.pake.outbound : this.peerPake;
419
+ const receiverMsg = this.role === "sender" ? this.peerPake : this.pake.outbound;
420
+ const salt = await sha256(TEXT_ENC.encode(`DSTP/1:${this.channel}`), senderMsg, receiverMsg);
421
+ this.fileKey = await deriveFileKey(master, salt);
422
+ this.keyReadyResolve();
423
+ if (this.role === "sender" && this.source && this.dc) void this.sendEncrypted(this.source, this.dc);
424
+ }
425
+ async handleData(data) {
426
+ if (typeof data === "string") {
427
+ const msg = JSON.parse(data);
428
+ if (msg.kind === "pake" && msg.data) {
429
+ this.peerPake = fromB64(msg.data);
430
+ await this.maybeCompleteHandshake();
431
+ } else if (msg.kind === "ready") {
432
+ this.peerReadyResolve();
433
+ } else if (msg.kind === "done-ack") {
434
+ this.peerDoneResolve();
435
+ }
436
+ return;
437
+ }
438
+ await this.keyReady;
439
+ if (!this.fileKey) return;
440
+ let plain;
441
+ try {
442
+ plain = new Uint8Array(await decryptFrame(this.fileKey, this.recvCounter++, data));
443
+ } catch {
444
+ this.fail("Decryption failed \u2014 wrong code, or the data was tampered with.");
445
+ return;
446
+ }
447
+ const type = plain[0];
448
+ const payload = plain.subarray(1);
449
+ if (type === TYPE_META) {
450
+ const meta = JSON.parse(TEXT_DEC.decode(payload));
451
+ this.recvMeta = meta;
452
+ this.recvBytes = 0;
453
+ if (meta.protected && meta.salt && meta.verifier) {
454
+ this.vaultSalt = fromB64(meta.salt);
455
+ this.vaultVerifier = fromB64(meta.verifier);
456
+ this.patch({
457
+ phase: "awaiting-passphrase",
458
+ fileName: meta.name,
459
+ fileSize: meta.size,
460
+ protected: true
461
+ });
462
+ return;
463
+ }
464
+ await this.prepareSink(meta);
465
+ } else if (type === TYPE_CHUNK) {
466
+ let chunk = payload;
467
+ if (this.vaultKey) {
468
+ try {
469
+ chunk = new Uint8Array(await decryptFrame(this.vaultKey, this.innerRecvCounter++, bs(payload)));
470
+ } catch {
471
+ this.fail("Decryption failed \u2014 wrong passphrase, or the data was tampered with.");
472
+ return;
473
+ }
474
+ }
475
+ this.recvBytes += chunk.length;
476
+ await this.sink?.write(chunk);
477
+ this.emitProgress(this.recvBytes, this.recvMeta?.size ?? 0);
478
+ } else if (type === TYPE_DONE) {
479
+ if (this.recvMeta && this.recvBytes !== this.recvMeta.size) {
480
+ await this.sink?.abort();
481
+ this.fail("Transfer ended early \u2014 the file looks incomplete.");
482
+ } else {
483
+ await this.sink?.close();
484
+ if (this.dc?.readyState === "open") this.dc.send(JSON.stringify({ kind: "done-ack" }));
485
+ this.finishOk(this.recvBytes);
486
+ }
487
+ }
488
+ }
489
+ /** Receiver: ask the host how to save, then act on its decision. */
490
+ async prepareSink(meta) {
491
+ const decision = await this.env.prepareSink(meta);
492
+ if (decision.kind === "error") {
493
+ this.fail(decision.message);
494
+ return;
495
+ }
496
+ if (decision.kind === "deferred") {
497
+ this.patch({ phase: "awaiting-save", fileName: meta.name, fileSize: meta.size });
498
+ return;
499
+ }
500
+ this.sink = decision.sink;
501
+ this.sendReady();
502
+ this.startProgress();
503
+ this.patch({
504
+ phase: "transferring",
505
+ fileName: meta.name,
506
+ fileSize: meta.size,
507
+ transferred: 0,
508
+ progress: 0
509
+ });
510
+ }
511
+ sendReady() {
512
+ if (this.dc?.readyState === "open") this.dc.send(JSON.stringify({ kind: "ready" }));
513
+ }
514
+ async sendEncrypted(source, dc) {
515
+ let salt;
516
+ let verifier;
517
+ if (this.passphrase) {
518
+ const mod = await this.loadCrypto();
519
+ const saltBytes = randomSalt();
520
+ this.vaultKey = await deriveVaultKey(mod, this.passphrase, saltBytes);
521
+ salt = toB64(saltBytes);
522
+ verifier = toB64(await makeVaultVerifier(this.vaultKey));
523
+ }
524
+ const metaBytes = TEXT_ENC.encode(
525
+ JSON.stringify({
526
+ name: source.name,
527
+ size: source.size,
528
+ mime: source.mime,
529
+ ...this.passphrase ? { protected: true, salt, verifier } : {}
530
+ })
531
+ );
532
+ await this.sendFrame(dc, TYPE_META, metaBytes);
533
+ await this.peerReady;
534
+ const maxMsg = this.pc?.sctp?.maxMessageSize || MAX_CHUNK_BYTES;
535
+ const chunkSize = Math.max(16 * 1024, Math.min(MAX_CHUNK_BYTES, maxMsg) - FRAME_OVERHEAD);
536
+ dc.bufferedAmountLowThreshold = LOW_WATER;
537
+ this.startProgress();
538
+ this.patch({ phase: "transferring", transferred: 0, progress: 0 });
539
+ let offset = 0;
540
+ while (offset < source.size && dc.readyState === "open") {
541
+ if (dc.bufferedAmount > HIGH_WATER) await this.waitDrain(dc);
542
+ const end = Math.min(offset + chunkSize, source.size);
543
+ let chunk = await source.slice(offset, end);
544
+ if (this.vaultKey) {
545
+ chunk = new Uint8Array(await encryptFrame(this.vaultKey, this.innerSendCounter++, chunk));
546
+ }
547
+ await this.sendFrame(dc, TYPE_CHUNK, chunk);
548
+ offset = end;
549
+ this.emitProgress(Math.max(0, offset - dc.bufferedAmount), source.size);
550
+ }
551
+ if (dc.readyState === "open") await this.sendFrame(dc, TYPE_DONE, new Uint8Array(0));
552
+ this.sentAllData = true;
553
+ while (dc.readyState === "open" && dc.bufferedAmount > 0) {
554
+ this.emitProgress(Math.max(0, source.size - dc.bufferedAmount), source.size);
555
+ await new Promise((r) => setTimeout(r, 100));
556
+ }
557
+ this.finishOk(source.size);
558
+ }
559
+ async sendFrame(dc, type, payload) {
560
+ if (!this.fileKey) return;
561
+ const plain = new Uint8Array(payload.length + 1);
562
+ plain[0] = type;
563
+ plain.set(payload, 1);
564
+ const ciphertext = await encryptFrame(this.fileKey, this.sendCounter++, plain);
565
+ if (dc.readyState !== "open") return;
566
+ try {
567
+ dc.send(bs(ciphertext));
568
+ } catch {
569
+ this.fail("The connection dropped mid-transfer. Please try again.");
570
+ }
571
+ }
572
+ waitDrain(dc) {
573
+ return new Promise((res) => {
574
+ const handler = () => {
575
+ dc.removeEventListener("bufferedamountlow", handler);
576
+ res();
577
+ };
578
+ dc.addEventListener("bufferedamountlow", handler);
579
+ });
580
+ }
581
+ async detectConnectionType() {
582
+ for (let attempt = 0; attempt < 6; attempt++) {
583
+ if (!this.pc || this.closed) return;
584
+ const type = await this.readConnectionType();
585
+ if (type !== "unknown") {
586
+ this.patch({ connectionType: type });
587
+ return;
588
+ }
589
+ await new Promise((r) => setTimeout(r, 200));
590
+ }
591
+ }
592
+ async readConnectionType() {
593
+ if (!this.pc) return "unknown";
594
+ try {
595
+ const stats = await this.pc.getStats();
596
+ let type = "unknown";
597
+ stats.forEach((report) => {
598
+ if (report.type === "candidate-pair" && (report.state === "succeeded" || report.nominated)) {
599
+ const local = stats.get(report.localCandidateId);
600
+ if (local?.candidateType) type = /relay/i.test(local.candidateType) ? "relay" : "direct";
601
+ }
602
+ });
603
+ return type;
604
+ } catch {
605
+ return "unknown";
606
+ }
607
+ }
608
+ // ---------- helpers ----------
609
+ /** Idempotently mark the transfer complete (either peer may trigger this). */
610
+ finishOk(transferred) {
611
+ if (this.completed) return;
612
+ this.completed = true;
613
+ this.patch({ phase: "done", progress: 1, transferred, speedBps: 0 });
614
+ this.recordHistory();
615
+ }
616
+ /** Record this completed transfer to history (paid plans only; metadata only). */
617
+ recordHistory() {
618
+ const sizeBytes = this.role === "sender" ? this.source?.size ?? 0 : this.recvMeta?.size ?? 0;
619
+ this.env.recordTransfer?.({
620
+ direction: this.role === "sender" ? "sent" : "received",
621
+ sizeBytes,
622
+ status: "completed"
623
+ });
624
+ }
625
+ startProgress() {
626
+ this.transferStart = now();
627
+ this.lastEmit = this.transferStart;
628
+ this.lastEmitBytes = 0;
629
+ }
630
+ emitProgress(transferred, total) {
631
+ const t = now();
632
+ if (t - this.lastEmit < PROGRESS_INTERVAL_MS) return;
633
+ const dt = (t - this.lastEmit) / 1e3;
634
+ const speed = dt > 0 ? (transferred - this.lastEmitBytes) / dt : 0;
635
+ this.lastEmit = t;
636
+ this.lastEmitBytes = transferred;
637
+ this.patch({
638
+ phase: "transferring",
639
+ transferred,
640
+ progress: total > 0 ? transferred / total : 0,
641
+ speedBps: speed
642
+ });
643
+ }
644
+ patch(p) {
645
+ if (this.closed && p.phase !== "error") return;
646
+ if (p.phase) this.phase = p.phase;
647
+ this.update(p);
648
+ }
649
+ fail(message) {
650
+ if (this.phase === "error") return;
651
+ this.patch({ phase: "error", error: message });
652
+ this.close();
653
+ }
654
+ errorText(code) {
655
+ switch (code) {
656
+ case "no_such_channel":
657
+ return "No transfer found for that code. Double-check it.";
658
+ case "already_claimed":
659
+ return "Someone already connected with that code.";
660
+ case "rate_limited":
661
+ return "Too many attempts \u2014 please wait a moment.";
662
+ case "expired":
663
+ return "That code has expired. Ask for a new one.";
664
+ case "unauthorized":
665
+ return "Not authorised.";
666
+ case "file_too_large":
667
+ return "This file is larger than your plan allows \u2014 upgrade for bigger transfers.";
668
+ case "concurrency_limit":
669
+ return "You've hit your simultaneous-transfer limit. Finish one, or upgrade.";
670
+ case "quota_exceeded":
671
+ return "You've used all your transfers this month. Upgrade for more.";
672
+ default:
673
+ return "Something went wrong. Please try again.";
674
+ }
675
+ }
676
+ };
677
+
678
+ // src/env-node.ts
679
+ import { createRequire } from "node:module";
680
+ import { createWriteStream } from "node:fs";
681
+ import { open, mkdir } from "node:fs/promises";
682
+ import { basename, join, dirname } from "node:path";
683
+ import { WebSocket } from "ws";
684
+ import { PeerConnection } from "node-datachannel";
685
+ import { RTCPeerConnection as NDCPeerConnection } from "node-datachannel/polyfill";
686
+ var require2 = createRequire(import.meta.url);
687
+ var NodeSignaling = class {
688
+ constructor(url) {
689
+ this.url = url;
690
+ }
691
+ ws;
692
+ onMessage;
693
+ onClose;
694
+ connect() {
695
+ return new Promise((resolve, reject) => {
696
+ const ws = new WebSocket(this.url);
697
+ this.ws = ws;
698
+ let opened = false;
699
+ ws.on("open", () => {
700
+ opened = true;
701
+ resolve();
702
+ });
703
+ ws.on("error", () => {
704
+ if (!opened) reject(new Error("signalling connection failed"));
705
+ });
706
+ ws.on("close", () => this.onClose?.());
707
+ ws.on("message", (raw) => {
708
+ try {
709
+ this.onMessage?.(JSON.parse(raw.toString()));
710
+ } catch {
711
+ }
712
+ });
713
+ });
714
+ }
715
+ send(msg) {
716
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
717
+ }
718
+ close() {
719
+ this.ws?.close();
720
+ }
721
+ };
722
+ function toNativeIceServers(servers) {
723
+ const out3 = [];
724
+ for (const s of servers) {
725
+ const urls = Array.isArray(s.urls) ? s.urls : [s.urls];
726
+ for (const url of urls) {
727
+ const turn = /^(turns?):([^:?]+):(\d+)(?:\?transport=(udp|tcp))?/i.exec(url);
728
+ if (turn && s.username && s.credential) {
729
+ const scheme = turn[1].toLowerCase();
730
+ const transport = (turn[4] ?? "udp").toLowerCase();
731
+ const relayType = scheme === "turns" ? "TurnTls" : transport === "tcp" ? "TurnTcp" : "TurnUdp";
732
+ out3.push({
733
+ hostname: turn[2],
734
+ port: Number(turn[3]),
735
+ username: s.username,
736
+ password: s.credential,
737
+ relayType
738
+ });
739
+ } else {
740
+ out3.push(url);
741
+ }
742
+ }
743
+ }
744
+ return out3;
745
+ }
746
+ var pcSeq = 0;
747
+ function makePeerFactory(iceTransportPolicy) {
748
+ return (iceServers) => {
749
+ const config = { iceServers: toNativeIceServers(iceServers) };
750
+ if (iceTransportPolicy === "relay") config.iceTransportPolicy = "relay";
751
+ const native = new PeerConnection(`drop2p-${++pcSeq}`, config);
752
+ return new NDCPeerConnection({ peerConnection: native });
753
+ };
754
+ }
755
+ var cryptoMod;
756
+ async function loadCrypto() {
757
+ if (!cryptoMod) cryptoMod = require2("./wasm/drop2p_crypto_wasm.cjs");
758
+ return cryptoMod;
759
+ }
760
+ function safeName(name) {
761
+ const base = basename(name).replace(/\0/g, "").trim();
762
+ return base && base !== "." && base !== ".." ? base : "drop2p-download";
763
+ }
764
+ function createFsSink(filePath) {
765
+ const stream = createWriteStream(filePath);
766
+ return {
767
+ write: (chunk) => new Promise((res, rej) => {
768
+ stream.write(chunk, (err3) => err3 ? rej(err3) : res());
769
+ }),
770
+ close: () => new Promise((res, rej) => {
771
+ stream.end((err3) => err3 ? rej(err3) : res());
772
+ }),
773
+ abort: async () => {
774
+ stream.destroy();
775
+ }
776
+ };
777
+ }
778
+ async function openFileSource(filePath) {
779
+ const fh = await open(filePath, "r");
780
+ const { size } = await fh.stat();
781
+ return {
782
+ name: basename(filePath),
783
+ size,
784
+ mime: guessMime(filePath),
785
+ slice: async (start, end) => {
786
+ const len = end - start;
787
+ const buf = Buffer.allocUnsafe(len);
788
+ const { bytesRead } = await fh.read(buf, 0, len, start);
789
+ return new Uint8Array(buf.buffer, buf.byteOffset, bytesRead);
790
+ }
791
+ };
792
+ }
793
+ function guessMime(path) {
794
+ const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
795
+ const map = {
796
+ txt: "text/plain",
797
+ json: "application/json",
798
+ pdf: "application/pdf",
799
+ png: "image/png",
800
+ jpg: "image/jpeg",
801
+ jpeg: "image/jpeg",
802
+ gif: "image/gif",
803
+ zip: "application/zip",
804
+ gz: "application/gzip",
805
+ tar: "application/x-tar",
806
+ mp4: "video/mp4",
807
+ mov: "video/quicktime",
808
+ mp3: "audio/mpeg",
809
+ csv: "text/csv"
810
+ };
811
+ return map[ext] ?? "application/octet-stream";
812
+ }
813
+ function createNodeEnv(opts) {
814
+ const outDir = opts.outDir ?? process.cwd();
815
+ const sinkPath = (meta) => join(outDir, safeName(meta.name));
816
+ const openSink = async (meta) => {
817
+ const target = sinkPath(meta);
818
+ await mkdir(dirname(target), { recursive: true });
819
+ return createFsSink(target);
820
+ };
821
+ return {
822
+ signalingUrl: opts.signalingUrl,
823
+ createSignaling: (url) => new NodeSignaling(url),
824
+ createPeerConnection: makePeerFactory(opts.iceTransportPolicy),
825
+ loadCrypto,
826
+ // The CLI never needs a user gesture — open the sink immediately.
827
+ prepareSink: async (meta) => ({ kind: "ready", sink: await openSink(meta) }),
828
+ createDeferredSink: openSink,
829
+ getAuthToken: () => opts.authToken ?? void 0,
830
+ recordTransfer: opts.recordTransfer
831
+ };
832
+ }
833
+ function resolveOutPath(outDir, name) {
834
+ return join(outDir, safeName(name));
835
+ }
836
+
837
+ // src/config.ts
838
+ import { homedir } from "node:os";
839
+ import { join as join2 } from "node:path";
840
+ import { readFile, writeFile, mkdir as mkdir2, chmod } from "node:fs/promises";
841
+ var DIR = join2(homedir(), ".config", "drop2p");
842
+ var CONFIG_PATH = join2(DIR, "config.json");
843
+ async function loadConfig() {
844
+ try {
845
+ return JSON.parse(await readFile(CONFIG_PATH, "utf8"));
846
+ } catch {
847
+ return {};
848
+ }
849
+ }
850
+ async function saveConfig(patch) {
851
+ const next = { ...await loadConfig(), ...patch };
852
+ await mkdir2(DIR, { recursive: true });
853
+ await writeFile(CONFIG_PATH, JSON.stringify(next, null, 2));
854
+ await chmod(CONFIG_PATH, 384).catch(() => {
855
+ });
856
+ }
857
+ async function clearAuth() {
858
+ const cur = await loadConfig();
859
+ delete cur.apiKey;
860
+ delete cur.accessToken;
861
+ delete cur.refreshToken;
862
+ delete cur.email;
863
+ await mkdir2(DIR, { recursive: true });
864
+ await writeFile(CONFIG_PATH, JSON.stringify(cur, null, 2));
865
+ }
866
+ var resolveApiUrl = (cfg) => process.env.DROP2P_API_URL ?? cfg.apiUrl ?? "http://localhost:34140";
867
+ var resolveSignalingUrl = (cfg) => process.env.DROP2P_SIGNALING_URL ?? cfg.signalingUrl ?? "ws://localhost:34110/ws";
868
+ var resolveAuthToken = (cfg) => process.env.DROP2P_API_KEY ?? cfg.apiKey ?? cfg.accessToken ?? void 0;
869
+
870
+ // src/account.ts
871
+ import { createInterface } from "node:readline/promises";
872
+ var out = (s = "") => {
873
+ process.stdout.write(s + "\n");
874
+ };
875
+ var err = (s = "") => {
876
+ process.stderr.write(s + "\n");
877
+ };
878
+ var CliError = class extends Error {
879
+ };
880
+ async function prompt(q) {
881
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
882
+ try {
883
+ return (await rl.question(q)).trim();
884
+ } finally {
885
+ rl.close();
886
+ }
887
+ }
888
+ function authHeaders(token, init = {}) {
889
+ const headers = {
890
+ ...init.headers,
891
+ authorization: `Bearer ${token}`
892
+ };
893
+ if (init.body != null) headers["content-type"] = "application/json";
894
+ return { ...init, headers };
895
+ }
896
+ async function jwtCall(path, init = {}) {
897
+ const cfg = await loadConfig();
898
+ if (!cfg.accessToken) {
899
+ throw new CliError("This needs an account login. Run: drop2p login --email <you@example.com> --password <password>");
900
+ }
901
+ const url = resolveApiUrl(cfg);
902
+ let r = await fetch(url + path, authHeaders(cfg.accessToken, init));
903
+ if (r.status === 401 && cfg.refreshToken) {
904
+ const rr = await fetch(url + "/auth/refresh", {
905
+ method: "POST",
906
+ headers: { "content-type": "application/json" },
907
+ body: JSON.stringify({ refreshToken: cfg.refreshToken })
908
+ });
909
+ if (rr.ok) {
910
+ const j = await rr.json();
911
+ await saveConfig({ accessToken: j.accessToken });
912
+ r = await fetch(url + path, authHeaders(j.accessToken, init));
913
+ }
914
+ }
915
+ return r;
916
+ }
917
+ async function login(flags) {
918
+ if (flags.email || flags.password) {
919
+ const email = flags.email ?? await prompt("Email: ");
920
+ const password = flags.password ?? await prompt("Password: ");
921
+ const cfg2 = await loadConfig();
922
+ const r2 = await fetch(resolveApiUrl(cfg2) + "/auth/login", {
923
+ method: "POST",
924
+ headers: { "content-type": "application/json" },
925
+ body: JSON.stringify({ email, password })
926
+ });
927
+ const j = await r2.json().catch(() => ({}));
928
+ if (!r2.ok || !j.accessToken) throw new CliError(j.error ?? "Login failed.");
929
+ await saveConfig({ accessToken: j.accessToken, refreshToken: j.refreshToken, email: j.user?.email });
930
+ out(`Logged in as ${j.user?.email} (${j.user?.plan}). Config: ${CONFIG_PATH}`);
931
+ return;
932
+ }
933
+ const key = flags.key ?? await prompt("Paste your Drop2p API key (d2p_\u2026): ");
934
+ if (!key.startsWith("d2p_")) throw new CliError("That doesn't look like a Drop2p API key (expected a d2p_\u2026 value).");
935
+ const cfg = await loadConfig();
936
+ const r = await fetch(resolveApiUrl(cfg) + "/v1/me", authHeaders(key));
937
+ if (!r.ok) throw new CliError("That API key was rejected. Check it (or that it isn't revoked).");
938
+ const me = await r.json();
939
+ await saveConfig({ apiKey: key });
940
+ out(`Logged in as ${me.email} (${me.plan}) via API key. Config: ${CONFIG_PATH}`);
941
+ }
942
+ async function logout() {
943
+ await clearAuth();
944
+ out("Logged out (cleared stored credentials).");
945
+ }
946
+ async function whoami(flags) {
947
+ const cfg = await loadConfig();
948
+ const token = resolveAuthToken(cfg);
949
+ if (!token) throw new CliError("Not logged in. Run `drop2p login`.");
950
+ const r = await fetch(resolveApiUrl(cfg) + "/v1/me", authHeaders(token));
951
+ if (!r.ok) throw new CliError("Not authenticated (token rejected). Try `drop2p login` again.");
952
+ const me = await r.json();
953
+ if (flags.json) return out(JSON.stringify(me));
954
+ out(`${me.email} \xB7 plan: ${me.plan}${me.role === "admin" ? " \xB7 admin" : ""}`);
955
+ }
956
+ async function keys(sub, args, flags) {
957
+ if (sub === "create") {
958
+ const name = args[0] ?? "API key";
959
+ const r2 = await jwtCall("/v1/keys", { method: "POST", body: JSON.stringify({ name }) });
960
+ const j2 = await r2.json().catch(() => ({}));
961
+ if (r2.status === 403) throw new CliError(j2.error ?? "API keys are an Ultimate feature.");
962
+ if (!r2.ok || !j2.key) throw new CliError(j2.error ?? "Couldn't create the key.");
963
+ if (flags.json) return out(JSON.stringify(j2));
964
+ out(`
965
+ New API key (shown once \u2014 store it now):
966
+
967
+ ${j2.key}
968
+ `);
969
+ err(" Use it via DROP2P_API_KEY or `drop2p login --key <key>`.");
970
+ return;
971
+ }
972
+ if (sub === "revoke") {
973
+ const id = args[0];
974
+ if (!id) throw new CliError("Usage: drop2p keys revoke <id>");
975
+ const r2 = await jwtCall(`/v1/keys/${id}`, { method: "DELETE" });
976
+ if (r2.status === 404) throw new CliError("No such key (already removed?).");
977
+ if (!r2.ok) throw new CliError(`Couldn't revoke the key (HTTP ${r2.status}).`);
978
+ const body = await r2.json().catch(() => ({}));
979
+ out(body.deleted ? `Deleted ${id}.` : `Revoked ${id} (run again to delete it).`);
980
+ return;
981
+ }
982
+ const r = await jwtCall("/v1/keys");
983
+ if (!r.ok) throw new CliError("Couldn't list keys.");
984
+ const j = await r.json();
985
+ if (flags.json) return out(JSON.stringify(j.keys));
986
+ if (j.keys.length === 0) return out("No API keys yet. Create one with: drop2p keys create <name>");
987
+ for (const k of j.keys) {
988
+ const status = k.revokedAt ? "revoked" : "active";
989
+ const last = k.lastUsedAt ? `last used ${new Date(k.lastUsedAt).toISOString().slice(0, 10)}` : "never used";
990
+ out(` ${k.id} ${k.prefix}\u2026 "${k.name}" [${status}] ${last}`);
991
+ }
992
+ }
993
+ async function history(flags) {
994
+ const cfg = await loadConfig();
995
+ const token = resolveAuthToken(cfg);
996
+ if (!token) throw new CliError("Not logged in. Run `drop2p login`.");
997
+ const r = await fetch(resolveApiUrl(cfg) + "/v1/history", authHeaders(token));
998
+ if (!r.ok) throw new CliError("Couldn't fetch history.");
999
+ const j = await r.json();
1000
+ const rows = typeof flags.limit === "number" ? j.transfers.slice(0, flags.limit) : j.transfers;
1001
+ if (flags.json) return out(JSON.stringify(rows));
1002
+ if (rows.length === 0) return out("No transfers recorded (history is a Pro/Ultimate perk).");
1003
+ for (const t of rows) {
1004
+ const when = new Date(t.createdAt).toISOString().replace("T", " ").slice(0, 16);
1005
+ out(` ${when} ${String(t.direction).padEnd(8)} ${String(t.status).padEnd(9)} ${Number(t.sizeBytes).toLocaleString()} bytes`);
1006
+ }
1007
+ }
1008
+
1009
+ // src/cli.ts
1010
+ function parse(argv) {
1011
+ const [cmd, ...rest] = argv;
1012
+ const positional = [];
1013
+ const flags = {};
1014
+ const val = (a, name, i) => a.startsWith(`--${name}=`) ? a.slice(name.length + 3) : rest[++i.v];
1015
+ for (const box = { v: 0 }; box.v < rest.length; box.v++) {
1016
+ const a = rest[box.v];
1017
+ if (a === "--json") flags.json = true;
1018
+ else if (a === "--out-code-only") flags.outCodeOnly = true;
1019
+ else if (a === "--yes" || a === "-y") flags.yes = true;
1020
+ else if (a === "--password" || a.startsWith("--password=")) flags.password = val(a, "password", box);
1021
+ else if (a === "--out" || a.startsWith("--out=")) flags.out = val(a, "out", box);
1022
+ else if (a === "--key" || a.startsWith("--key=")) flags.key = val(a, "key", box);
1023
+ else if (a === "--email" || a.startsWith("--email=")) flags.email = val(a, "email", box);
1024
+ else if (a === "--limit" || a.startsWith("--limit=")) flags.limit = Number(val(a, "limit", box));
1025
+ else positional.push(a);
1026
+ }
1027
+ return { cmd, positional, flags };
1028
+ }
1029
+ var fmtBytes = (n) => {
1030
+ if (n < 1024) return `${n} B`;
1031
+ const u = ["KB", "MB", "GB", "TB"];
1032
+ let v = n / 1024;
1033
+ let i = 0;
1034
+ while (v >= 1024 && i < u.length - 1) {
1035
+ v /= 1024;
1036
+ i++;
1037
+ }
1038
+ return `${v.toFixed(1)} ${u[i]}`;
1039
+ };
1040
+ var fmtDur = (secs) => {
1041
+ if (!isFinite(secs) || secs < 0) return "\u2014";
1042
+ const m = Math.floor(secs / 60);
1043
+ const s = Math.floor(secs % 60);
1044
+ return `${m}:${String(s).padStart(2, "0")}`;
1045
+ };
1046
+ var progressBar = (done, total, progress, speedBps) => {
1047
+ const frac = progress != null ? progress : total > 0 ? done / total : 0;
1048
+ const width = 22;
1049
+ const filled = Math.max(0, Math.min(width, Math.round(frac * width)));
1050
+ const meter = "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
1051
+ const pct = String(Math.round(frac * 100)).padStart(3, " ");
1052
+ const speed = speedBps ? ` \xB7 ${fmtBytes(speedBps)}/s` : "";
1053
+ const eta = speedBps && total > done ? ` \xB7 ETA ${fmtDur((total - done) / speedBps)}` : "";
1054
+ return ` [${meter}] ${pct}% ${fmtBytes(done)} / ${fmtBytes(total)}${speed}${eta}`;
1055
+ };
1056
+ var out2 = (s) => {
1057
+ process.stdout.write(s + "\n");
1058
+ };
1059
+ var err2 = (s) => {
1060
+ process.stderr.write(s + "\n");
1061
+ };
1062
+ function exit(code) {
1063
+ setTimeout(() => process.exit(code), 150);
1064
+ return void 0;
1065
+ }
1066
+ var VERSION = true ? "0.1.0" : "dev";
1067
+ var USAGE = [
1068
+ "drop2p \u2014 zero-storage, end-to-end-encrypted file transfer in your terminal.",
1069
+ "",
1070
+ "Usage:",
1071
+ " drop2p send <file> [--password X] [--out-code-only] [--json]",
1072
+ " drop2p receive <code> [--out DIR] [--password X] [--json]",
1073
+ " drop2p login [--key d2p_\u2026] | [--email X --password Y]",
1074
+ " drop2p whoami | logout",
1075
+ " drop2p keys (create <name> | list | revoke <id>)",
1076
+ " drop2p history [--limit N] [--json]",
1077
+ " drop2p --version | --help",
1078
+ "",
1079
+ "Env: DROP2P_API_KEY \xB7 DROP2P_PASSWORD \xB7 DROP2P_SIGNALING_URL \xB7 DROP2P_API_URL"
1080
+ ].join("\n");
1081
+ async function main() {
1082
+ const { cmd, positional, flags } = parse(process.argv.slice(2));
1083
+ const json = Boolean(flags.json);
1084
+ const emit = (event, data = {}) => {
1085
+ if (json) out2(JSON.stringify({ event, ...data }));
1086
+ };
1087
+ if (cmd === "--version" || cmd === "-v" || cmd === "version") return out2(VERSION);
1088
+ if (cmd === "--help" || cmd === "-h" || cmd === "help") return out2(USAGE);
1089
+ if (cmd === "send") return send(positional[0], flags, emit);
1090
+ if (cmd === "receive") return receive(positional[0], flags, emit);
1091
+ if (cmd === "login" || cmd === "logout" || cmd === "whoami" || cmd === "keys" || cmd === "history") {
1092
+ try {
1093
+ if (cmd === "login") await login(flags);
1094
+ else if (cmd === "logout") await logout();
1095
+ else if (cmd === "whoami") await whoami(flags);
1096
+ else if (cmd === "keys") await keys(positional[0], positional.slice(1), flags);
1097
+ else if (cmd === "history") await history(flags);
1098
+ } catch (e) {
1099
+ if (e instanceof CliError) {
1100
+ err2(` \u274C ${e.message}`);
1101
+ process.exit(1);
1102
+ }
1103
+ throw e;
1104
+ }
1105
+ return;
1106
+ }
1107
+ err2(USAGE);
1108
+ process.exit(2);
1109
+ }
1110
+ async function send(filePath, flags, emit) {
1111
+ if (!filePath) {
1112
+ err2("error: missing <file>");
1113
+ process.exit(2);
1114
+ }
1115
+ let source;
1116
+ try {
1117
+ source = await openFileSource(filePath);
1118
+ } catch {
1119
+ err2(`error: can't read ${filePath}`);
1120
+ process.exit(2);
1121
+ }
1122
+ const password = flags.password ?? process.env.DROP2P_PASSWORD;
1123
+ const cfg = await loadConfig();
1124
+ const env = createNodeEnv({
1125
+ signalingUrl: resolveSignalingUrl(cfg),
1126
+ authToken: resolveAuthToken(cfg),
1127
+ iceTransportPolicy: process.env.DROP2P_FORCE_RELAY ? "relay" : void 0
1128
+ });
1129
+ let printedCode = false;
1130
+ let finished = false;
1131
+ let lastLine = 0;
1132
+ const mgr = new TransferManager(env, (s) => {
1133
+ if (s.code && !printedCode) {
1134
+ printedCode = true;
1135
+ emit("code", { code: s.code, expiresAt: s.expiresAt });
1136
+ if (flags.outCodeOnly || flags.json) out2(s.code);
1137
+ else {
1138
+ out2(`
1139
+ Share this code with the receiver:
1140
+
1141
+ ${s.code}
1142
+ `);
1143
+ if (process.stderr.isTTY && s.code) qrcode.generate(s.code, { small: true }, (qr) => err2(qr));
1144
+ err2(" Waiting for the other side to connect\u2026");
1145
+ }
1146
+ }
1147
+ if (s.connectionType && s.connectionType !== "unknown") {
1148
+ emit("connected", { via: s.connectionType });
1149
+ if (!flags.json) err2(` Connected (${s.connectionType === "relay" ? "\u{1F6F0} relay" : "\u26A1 direct"}). Sending\u2026`);
1150
+ }
1151
+ if (s.phase === "transferring" && s.transferred != null && !flags.json) {
1152
+ const now2 = Date.now();
1153
+ if (now2 - lastLine > 200) {
1154
+ lastLine = now2;
1155
+ process.stderr.write("\r" + progressBar(s.transferred, source.size, s.progress, s.speedBps) + " ");
1156
+ }
1157
+ }
1158
+ if (s.transferred != null && s.phase === "transferring") emit("progress", { transferred: s.transferred, total: source.size, speedBps: s.speedBps });
1159
+ if (s.phase === "done" && !finished) {
1160
+ finished = true;
1161
+ void (async () => {
1162
+ await Promise.race([mgr.peerDone, new Promise((r) => setTimeout(r, 8e3))]);
1163
+ if (!flags.json) err2(`
1164
+ \u2705 Sent ${source.name} (${fmtBytes(source.size)}).`);
1165
+ emit("done", { name: source.name, size: source.size });
1166
+ mgr.close();
1167
+ exit(0);
1168
+ })();
1169
+ }
1170
+ if (s.phase === "error") {
1171
+ if (!flags.json) err2(`
1172
+ \u274C ${s.error}`);
1173
+ emit("error", { message: s.error });
1174
+ exit(1);
1175
+ }
1176
+ });
1177
+ await mgr.sendFile(source, { passphrase: password });
1178
+ }
1179
+ async function receive(code, flags, emit) {
1180
+ if (!code) {
1181
+ err2("error: missing <code>");
1182
+ process.exit(2);
1183
+ }
1184
+ const outDir = flags.out ?? process.cwd();
1185
+ const password = flags.password ?? process.env.DROP2P_PASSWORD;
1186
+ const cfg = await loadConfig();
1187
+ const env = createNodeEnv({
1188
+ signalingUrl: resolveSignalingUrl(cfg),
1189
+ outDir,
1190
+ authToken: resolveAuthToken(cfg),
1191
+ iceTransportPolicy: process.env.DROP2P_FORCE_RELAY ? "relay" : void 0
1192
+ });
1193
+ let total = 0;
1194
+ let name = "";
1195
+ let lastLine = 0;
1196
+ let askedPassphrase = false;
1197
+ const mgr = new TransferManager(env, (s) => {
1198
+ if (s.fileSize != null) total = s.fileSize;
1199
+ if (s.fileName) name = s.fileName;
1200
+ if (s.connectionType && s.connectionType !== "unknown") {
1201
+ emit("connected", { via: s.connectionType });
1202
+ if (!flags.json) err2(` Connected (${s.connectionType === "relay" ? "\u{1F6F0} relay" : "\u26A1 direct"}). Receiving\u2026`);
1203
+ }
1204
+ if (s.error && askedPassphrase && s.phase !== "error") {
1205
+ if (!flags.json) err2(` \u274C ${s.error}`);
1206
+ emit("error", { message: s.error });
1207
+ mgr.close();
1208
+ exit(1);
1209
+ return;
1210
+ }
1211
+ if (s.phase === "awaiting-passphrase" && !askedPassphrase) {
1212
+ askedPassphrase = true;
1213
+ if (!password) {
1214
+ if (!flags.json) err2(" \u274C This transfer is passphrase-protected. Pass --password or set DROP2P_PASSWORD.");
1215
+ emit("error", { message: "passphrase required" });
1216
+ mgr.close();
1217
+ exit(1);
1218
+ return;
1219
+ }
1220
+ void mgr.submitPassphrase(password);
1221
+ }
1222
+ if (s.phase === "transferring" && s.transferred != null && !flags.json) {
1223
+ const now2 = Date.now();
1224
+ if (now2 - lastLine > 200) {
1225
+ lastLine = now2;
1226
+ process.stderr.write("\r" + progressBar(s.transferred, total, s.progress, s.speedBps) + " ");
1227
+ }
1228
+ }
1229
+ if (s.transferred != null && s.phase === "transferring") emit("progress", { transferred: s.transferred, total, speedBps: s.speedBps });
1230
+ if (s.phase === "done") {
1231
+ const dest = resolveOutPath(outDir, name || "drop2p-download");
1232
+ if (!flags.json) err2(`
1233
+ \u2705 Received \u2192 ${dest}`);
1234
+ emit("done", { path: dest, size: total });
1235
+ mgr.close();
1236
+ exit(0);
1237
+ }
1238
+ if (s.phase === "error") {
1239
+ if (!flags.json) err2(`
1240
+ \u274C ${s.error}`);
1241
+ emit("error", { message: s.error });
1242
+ exit(1);
1243
+ }
1244
+ });
1245
+ if (!flags.json) err2(" Connecting\u2026");
1246
+ await mgr.receive(code);
1247
+ }
1248
+ void main();