filenymous 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,122 @@
1
- # Temporary Holding Version
1
+ # filenymous — CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Partage de fichiers chiffré de bout en bout, **100 % pair-à-pair**, depuis le terminal ou la CI.
4
+ End-to-end encrypted, **100 % peer-to-peer** file sharing from your terminal or CI.
5
+
6
+ ```bash
7
+ npx filenymous upload app.zip --ttl 1h
8
+ # → https://filenymous.eu/app/#p2p:123-456-ABC/xxxxxxxxxxxxxxxxxxxxxx
9
+ ```
10
+
11
+ [![npm](https://img.shields.io/npm/v/filenymous)](https://www.npmjs.com/package/filenymous)
12
+
13
+ **Installation** : `npx filenymous …` (sans installation) ou `npm i -g filenymous` — Node ≥ 18.17.
14
+
15
+ [Français](#français) · [English](#english) · [GitHub Actions](#github-actions)
16
+
17
+ ---
18
+
19
+ ## Français
20
+
21
+ ### Principe
22
+ - La CLI est un **pair émetteur** : elle utilise exactement le protocole du transfert direct de l'application web (lien `#p2p:CODE/SECRET`, AES-256-GCM par bloc de 64 Kio, mot de passe optionnel).
23
+ - Le lien s'ouvre dans n'importe quel navigateur sur https://filenymous.eu/app/, ou avec `filenymous receive`.
24
+ - **Rien n'est stocké sur un serveur.** Le relais de signalisation (`wss://www.opendpe.net/filenymous-signal/`) ne fait que mettre les deux pairs en relation. Le fichier passe en WebRTC, directement ou via TURN, et toujours chiffré.
25
+ - Conséquence : **le processus doit rester actif** jusqu'à la fin des téléchargements. Il s'arrête de lui-même après `--max-downloads` téléchargements ou à l'expiration du `--ttl`.
26
+
27
+ ### Envoyer
28
+ ```bash
29
+ filenymous upload <fichier...> [--ttl 1h] [--max-downloads 1] [--password X] \
30
+ [--webhook URL] [--webhook-no-link] [--allow-empty] [--json] [-q]
31
+ ```
32
+ - Le lien est écrit sur la **sortie standard**, la progression sur stderr. Avec `--json`, la sortie standard reçoit un événement JSON par ligne.
33
+ - `--password` : un mot de passe est demandé au destinataire. Il n'est jamais envoyé : c'est une preuve HMAC, et une dérivation PBKDF2 est mêlée à la clé AES. Préférez la variable d'environnement `FILENYMOUS_PASSWORD`, qui n'apparaît pas dans l'historique du shell ni dans les logs de CI.
34
+ - Codes de sortie :
35
+ - `0` : au moins un téléchargement terminé (ou `--allow-empty`)
36
+ - `2` : TTL expiré sans aucun téléchargement
37
+ - `1` : erreur, par exemple 5 mauvais mots de passe
38
+ - `130` : Ctrl-C
39
+
40
+ ### Recevoir
41
+ ```bash
42
+ filenymous receive "<lien ou code>" [-o dossier] [--password X] [--timeout 30m] [--json]
43
+ ```
44
+ - Les fichiers sont écrits en flux dans le dossier, sans jamais écraser un fichier existant (`nom (1).ext`).
45
+ - Les chemins reçus sont affichés sur la sortie standard.
46
+
47
+ ### Webhook
48
+ Pour chaque événement, la CLI envoie en `POST` (JSON) :
49
+ ```json
50
+ { "event": "share.created | download.completed | share.expired | share.failed",
51
+ "link": "https://filenymous.eu/app/#p2p:123-456-ABC/…", "code": "123-456-ABC",
52
+ "file": "app.zip", "size": 1234, "expiresAt": "2026-10-16T15:00:00.000Z",
53
+ "downloads": 1, "maxDownloads": 1, "protected": false }
54
+ ```
55
+ > ⚠️ **Le lien contient le secret** (après `#`) : quiconque lit le webhook peut télécharger le fichier tant que la CLI tourne. Le **code seul suffit aussi**, car le secret lui est rattaché. Utilisez `--webhook-no-link` pour retirer `link` et `code` du webhook, ou protégez le partage par `--password`.
56
+
57
+ ### Options communes
58
+ - `--signal <url>` : relais de signalisation (`FILENYMOUS_SIGNAL_URL`).
59
+ - `--app-url <url>` : base des liens (`FILENYMOUS_APP_URL`).
60
+
61
+ ---
62
+
63
+ ## English
64
+
65
+ ### How it works
66
+ - The CLI is a **sender peer**: it speaks the web app's direct-transfer protocol unchanged (`#p2p:CODE/SECRET` links, AES-256-GCM per 64 KiB chunk, optional password gate).
67
+ - Links open in any browser at https://filenymous.eu/app/, or with `filenymous receive`.
68
+ - **Nothing is stored server-side.** The signaling relay only introduces the peers; the data flows over WebRTC (direct or TURN-relayed), always encrypted.
69
+ - So **the process must stay alive** until downloads are done. It exits by itself after `--max-downloads` or when `--ttl` expires.
70
+
71
+ ### Send
72
+ ```bash
73
+ filenymous upload <file...> [--ttl 1h] [--max-downloads 1] [--password X] \
74
+ [--webhook URL] [--webhook-no-link] [--allow-empty] [--json] [-q]
75
+ ```
76
+ - The link goes to **stdout** and progress to stderr. With `--json`, stdout carries JSON lines events.
77
+ - Prefer `FILENYMOUS_PASSWORD` over `--password`, so the password stays out of shell history and CI logs.
78
+ - Exit codes:
79
+ - `0`: at least one completed download (or `--allow-empty`)
80
+ - `2`: TTL expired with no download
81
+ - `1`: error, e.g. 5 wrong passwords
82
+ - `130`: interrupted
83
+
84
+ ### Receive
85
+ ```bash
86
+ filenymous receive "<link or code>" [-o dir] [--password X] [--timeout 30m] [--json]
87
+ ```
88
+
89
+ ### Webhook
90
+ The JSON payload is the same as above, POSTed for `share.created`, `download.completed`, `share.expired` and `share.failed`.
91
+ > ⚠️ **The link carries the decryption secret** in its `#fragment`, and **the code alone is enough** too. Anyone who can read your webhook payloads can download the file while the CLI runs. Use `--webhook-no-link` (drops `link` and `code`) or `--password`.
92
+
93
+ ---
94
+
95
+ ## GitHub Actions
96
+
97
+ ```yaml
98
+ - name: Share the build for 1 hour (1 download)
99
+ env:
100
+ FILENYMOUS_PASSWORD: ${{ secrets.SHARE_PASSWORD }} # optional
101
+ run: |
102
+ npx --yes filenymous upload dist/app.zip --ttl 1h --max-downloads 1 \
103
+ --webhook "${{ secrets.SLACK_WEBHOOK_PROXY }}" --webhook-no-link --json > share.jsonl &
104
+ sleep 5 && head -1 share.jsonl | jq -r .code # announce the code, not the link, in public logs
105
+ wait # the job stays alive until the download completes or the TTL expires
106
+ ```
107
+ > The runner has to stay up while the recipient downloads (P2P). GitHub-hosted runners reach peers through the relay's TURN servers when a direct path isn't possible.
108
+
109
+ ---
110
+
111
+ ## Technique / Tech notes
112
+ - WebRTC: [`node-datachannel`](https://github.com/murat-dogan/node-datachannel) (libdatachannel, prebuilt binaries, ~1 MB), WebSocket: `ws`. No other runtime dependency.
113
+ - The protocol code is **the web app's own** `ui/src/p2p` (session, protocol, signal), bundled as-is with esbuild, so CLI and browser can't drift apart. Cross-compat is covered by Playwright (CLI → browser, browser → CLI) and a CLI ⇄ CLI loopback test.
114
+ - Node ≥ 18.17.
115
+
116
+ ## Développement
117
+ ```bash
118
+ cd cli && npm ci
119
+ npm run build # dist/filenymous.js (single file)
120
+ npm test # unit + CLI⇄CLI loopback against a local relay
121
+ cd ../ui && npx playwright test e2e/cli-interop.spec.ts # CLI ⇄ web app
122
+ ```
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import{createRequire as __cr}from'node:module';const require=__cr(import.meta.url);
3
+ import{parseArgs as et}from"node:util";import{createInterface as tt}from"node:readline";import{resolve as Ae}from"node:path";import{RTCPeerConnection as xe,RTCSessionDescription as Ce,RTCIceCandidate as Re,RTCDataChannel as _e}from"node-datachannel/polyfill";import Ie from"ws";function De(i){return i.replace(/^a=/,"")}var B=class extends xe{pendingLocal=0;remoteSet=!1;held=[];userHandler=null;constructor(e){super(e);let t=r=>{let n=r.candidate,s={candidate:n?{candidate:De(n.candidate),sdpMid:n.sdpMid??"0",sdpMLineIndex:n.sdpMLineIndex??0}:null};this.pendingLocal>0||!this.remoteSet?this.held.push(s):this.userHandler?.(s)};Object.defineProperty(this,"onicecandidate",{configurable:!0,get:()=>this.userHandler?t:null,set:r=>{this.userHandler=r}})}async createOffer(e){return this.pendingLocal=1,super.createOffer()}async createAnswer(e){return this.pendingLocal=1,super.createAnswer()}async setLocalDescription(e){this.pendingLocal=1;try{await super.setLocalDescription(e)}finally{setTimeout(()=>{this.pendingLocal=0,this.flush()},0)}}async setRemoteDescription(e){await super.setRemoteDescription(e),this.remoteSet=!0,this.flush()}flush(){if(!(this.pendingLocal>0||!this.remoteSet))for(let e of this.held.splice(0))this.userHandler?.(e)}};function V(i){let e=globalThis;e.RTCPeerConnection=B,e.RTCSessionDescription??=Ce,e.RTCIceCandidate??=Re,e.RTCDataChannel??=_e,e.WebSocket||(e.WebSocket=Ie),e.__FILENYMOUS_SIGNAL_URL__=i}import{open as Le,stat as Be}from"node:fs/promises";import{basename as Oe,extname as Me}from"node:path";var Fe={".zip":"application/zip",".gz":"application/gzip",".tgz":"application/gzip",".tar":"application/x-tar",".txt":"text/plain",".log":"text/plain",".json":"application/json",".pdf":"application/pdf",".png":"image/png",".jpg":"image/jpeg",".jpeg":"image/jpeg",".mp4":"video/mp4",".html":"text/html"};async function Z(i){let e=await Be(i);if(!e.isFile())throw new Error(`not a regular file: ${i}`);let t=await Le(i,"r"),r=e.size;return{name:Oe(i),size:r,type:Fe[Me(i).toLowerCase()]??"application/octet-stream",slice(n,s){let a=Math.max(0,Math.min(r,n)),o=Math.max(a,Math.min(r,s));return{async arrayBuffer(){let l=Buffer.alloc(o-a),f=0;for(;f<l.length;){let{bytesRead:m}=await t.read(l,f,l.length-f,a+f);if(m===0)throw new Error("file changed while sending");f+=m}return l.buffer.slice(l.byteOffset,l.byteOffset+l.length)}}},async close(){await t?.close(),t=null}}}import{createWriteStream as $e,existsSync as Ke,mkdirSync as He,rmSync as We}from"node:fs";import{extname as ze,join as he,basename as Ge}from"node:path";var Ue=/^(\d{3})(\d{3})([A-Z]{3})$/,X="ABCDEFGHJKLMNPQRSTUVWXYZ";function w(i){let e="";for(let t=0;t<i.length;t+=32768)e+=String.fromCharCode(...i.subarray(t,t+32768));return btoa(e).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function S(i){let e=i.replace(/-/g,"+").replace(/_/g,"/").padEnd(Math.ceil(i.length/4)*4,"="),t=atob(e),r=new Uint8Array(t.length);for(let n=0;n<t.length;n++)r[n]=t.charCodeAt(n);return r}function u(i){return i.slice().buffer}function Q(){let i=crypto.getRandomValues(new Uint32Array(4)),e=String(i[0]%1e6).padStart(6,"0"),t=[i[1],i[2],i[3]].map(r=>X[r%X.length]).join("");return`${e.slice(0,3)}-${e.slice(3)}-${t}`}function Ne(i){let t=String(i||"").toUpperCase().replace(/[^0-9A-Z]/g,"").match(Ue);return t?`${t[1]}-${t[2]}-${t[3]}`:null}function ee(){return w(crypto.getRandomValues(new Uint8Array(16)))}function te(i,e,t){return`${i.split("#")[0]}#p2p:${e}${t?`/${t}`:""}`}function ne(i){let e=String(i||"").trim();if(!e)return null;let t=e.indexOf("#");t>=0&&(e=e.slice(t+1));try{e=decodeURIComponent(e)}catch{}e.startsWith("p2p:")&&(e=e.slice(4));let[r,n]=e.split("/"),s=Ne(r??"");if(!s)return null;let a=n&&/^[A-Za-z0-9_-]{16,64}$/.test(n)?n:void 0;return a?{code:s,secret:a}:{code:s}}function O(i=16){return w(crypto.getRandomValues(new Uint8Array(i)))}async function M(i,e,t=31e4){let r=await crypto.subtle.importKey("raw",u(new TextEncoder().encode(i.normalize("NFC"))),"PBKDF2",!1,["deriveBits"]),n=new Uint8Array(await crypto.subtle.deriveBits({name:"PBKDF2",salt:u(S(e)),iterations:t,hash:"SHA-256"},r,512));return{auth:await crypto.subtle.importKey("raw",u(n.slice(0,32)),{name:"HMAC",hash:"SHA-256"},!1,["sign","verify"]),mix:n.slice(32)}}function re(i,e){return u(new TextEncoder().encode(`filenymous-p2p-auth-v3:${i}:${e}`))}async function ie(i,e,t){return w(new Uint8Array(await crypto.subtle.sign("HMAC",i.auth,re(e,t))))}async function se(i,e,t,r){try{return await crypto.subtle.verify("HMAC",i.auth,u(S(r)),re(e,t))}catch{return!1}}async function F(i,e){let t=S(i),r=t;e&&(r=new Uint8Array(t.length+e.length),r.set(t),r.set(e,t.length));let n=await crypto.subtle.importKey("raw",u(r),"HKDF",!1,["deriveKey"]);return crypto.subtle.deriveKey({name:"HKDF",hash:"SHA-256",salt:u(new TextEncoder().encode("filenymous-p2p-v2")),info:u(new TextEncoder().encode(e?"file-key+password":"file-key"))},n,{name:"AES-GCM",length:256},!1,["encrypt","decrypt"])}async function ae(i){let e=await crypto.subtle.importKey("raw",u(new TextEncoder().encode(`filenymous-p2p-code-v2:${i}`)),"PBKDF2",!1,["deriveKey"]);return crypto.subtle.deriveKey({name:"PBKDF2",salt:u(new TextEncoder().encode("filenymous-p2p-secret-wrap")),iterations:12e4,hash:"SHA-256"},e,{name:"AES-GCM",length:256},!1,["encrypt","decrypt"])}async function oe(i,e){let t=crypto.getRandomValues(new Uint8Array(12)),r=await crypto.subtle.encrypt({name:"AES-GCM",iv:t},await ae(i),u(S(e)));return`${w(t)}.${w(new Uint8Array(r))}`}async function ce(i,e){let[t,r]=String(e||"").split(".");if(!t||!r)throw new Error("invalid wrapped secret");let n=await crypto.subtle.decrypt({name:"AES-GCM",iv:u(S(t))},await ae(i),u(S(r)));return w(new Uint8Array(n))}function le(i,e){let t=new Uint8Array(12),r=new DataView(t.buffer);return r.setUint32(0,i),r.setUint32(4,Math.floor(e/2**32)),r.setUint32(8,e>>>0),t}async function de(i,e,t,r){let n=new Uint8Array(8),s=new DataView(n.buffer);s.setUint32(0,e),s.setUint32(4,t);let a=new Uint8Array(await crypto.subtle.encrypt({name:"AES-GCM",iv:u(le(e,t)),additionalData:u(n)},i,u(r))),o=new Uint8Array(8+a.byteLength);return o.set(n),o.set(a,8),o.buffer}async function pe(i,e){let t=new Uint8Array(e);if(t.byteLength<24)throw new Error("short frame");let r=t.subarray(0,8),n=new DataView(t.buffer,t.byteOffset,8),s=n.getUint32(0),a=n.getUint32(4),o=new Uint8Array(await crypto.subtle.decrypt({name:"AES-GCM",iv:u(le(s,a)),additionalData:u(r)},i,u(t.subarray(8))));return{fileIndex:s,chunkIndex:a,plain:o}}function U(i,e=65536){return i===0?0:Math.ceil(i/e)}function N(i){try{let e=JSON.parse(i);if(!e||typeof e!="object"||typeof e.type!="string")return null;if(e.type==="manifest"){if(!Array.isArray(e.files)||e.files.length===0||e.files.length>1e3||!e.files.every(t=>typeof t?.name=="string"&&Number.isSafeInteger(t.size)&&t.size>=0)||typeof e.wrappedSecret!="string"||!Number.isSafeInteger(e.chunkSize))return null;if(e.auth!==void 0){let t=e.auth;if(!t||typeof t.salt!="string"||typeof t.nonce!="string"||!Number.isSafeInteger(t.iter)||t.iter<1e5||t.iter>5e6)return null}}if("device"in e&&e.device!==void 0){if(typeof e.device!="string")return null;e.device=e.device.replace(/[\u0000-\u001f\u007f]/g,"").slice(0,80)}return e}catch{return null}}function ue(i){return String(i||"").replace(/[\u0000-\u001f\u007f]/g,"").replace(/[\\/:*?"<>|]/g,"_").replace(/^\.+/,"").trim().slice(0,200)||"file"}function Ye(i,e,t=Ke){let r=ue(e),n=ze(r),s=n?r.slice(0,-n.length):r,a=he(i,r);for(let o=1;t(a);o++)a=he(i,`${s} (${o})${n}`);return a}var x=class{constructor(e){this.dir=e;He(e,{recursive:!0})}kind="fs-dir";written=[];stream=null;path="";async open(e,t){this.path=Ye(this.dir,Ge(t.name)),this.stream=$e(this.path,{flags:"wx"}),await new Promise((r,n)=>this.stream.once("open",()=>r()).once("error",n))}async write(e){let t=this.stream;t.write(e)||await new Promise(r=>t.once("drain",()=>r()))}async close(){let e=this.stream;e&&(await new Promise((t,r)=>e.end(n=>n?r(n):t())),this.written.push(this.path),this.stream=null)}async abort(){let e=this.stream;if(this.stream=null,e){e.destroy();try{We(this.path,{force:!0})}catch{}}}};function $(i){let e=String(i).trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i);if(!e)throw new Error(`invalid duration: ${i} (examples: 90s, 15m, 1h, 2d)`);let t=Number(e[1]),r=(e[2]??"s").toLowerCase(),n=t*{ms:1,s:1e3,m:6e4,h:36e5,d:864e5}[r];if(!(n>=1e3)||n>7*864e5)throw new Error(`duration out of range (1s..7d): ${i}`);return Math.round(n)}function fe(i,e){if(!e)return i;let{link:t,code:r,...n}=i;return n}function b(i){let e=["B","KB","MB","GB","TB"],t=0,r=i;for(;r>=1024&&t<e.length-1;)r/=1024,t++;return`${t?r.toFixed(1):r} ${e[t]}`}var K="https://filenymous.eu/app/",H="wss://www.opendpe.net/filenymous-signal/";import{platform as me}from"node:os";function C(){return`Filenymous CLI \xB7 ${{linux:"Linux",darwin:"macOS",win32:"Windows"}[me()]??me()}`}var qe="wss://www.opendpe.net/filenymous-signal/",ye=[{urls:"stun:stun.l.google.com:19302"}];function we(i){let e=import.meta.env?.[i];return e&&e.trim()?e.trim():void 0}function z(){let i=globalThis.__FILENYMOUS_SIGNAL_URL__;if(i)return i;let e=we("VITE_P2P_SIGNAL_URL");if(e)return e;let t=typeof location<"u"?location.hostname:"";return t==="localhost"||t==="127.0.0.1"?"ws://127.0.0.1:8789":qe}function Je(i=z()){return i.replace(/^ws(s?):\/\//,"http$1://").replace(/\/+$/,"")}var W=null,ge=0;async function G(){let i=we("VITE_P2P_ICE_SERVERS");if(i)try{let e=JSON.parse(i);if(Array.isArray(e))return e}catch{}return(!W||Date.now()-ge>30*6e4)&&(ge=Date.now(),W=(async()=>{try{let e=new AbortController,t=setTimeout(()=>e.abort(),4e3),r=await fetch(`${Je()}/ice`,{signal:e.signal,cache:"no-store"});if(clearTimeout(t),!r.ok)throw new Error(String(r.status));let n=await r.json();return n.iceServers?.length?n.iceServers:ye}catch{return ye}})()),W}var P=class i{constructor(e,t,r,n=z()){this.code=e;this.role=t;this.handlers=r;this.url=n}ws=null;closed=!1;attempt=0;queue=[];connect(){return new Promise((e,t)=>{let r=!1,n=new WebSocket(this.url);this.ws=n;let s=setTimeout(()=>{if(!r){r=!0;try{n.close()}catch{}t(new Error("signal-timeout"))}},1e4);n.onopen=()=>n.send(JSON.stringify({type:"join",code:this.code,role:this.role})),n.onmessage=a=>{let o;try{o=JSON.parse(String(a.data))}catch{return}switch(o.type){case"joined":for(this.attempt=0,r||(r=!0,clearTimeout(s),e());this.queue.length;)n.send(this.queue.shift());break;case"peer-joined":this.handlers.onPeerJoined?.();break;case"peer-left":this.handlers.onPeerLeft?.();break;case"signal":o.payload&&this.handlers.onSignal(o.payload);break;case"error":r?this.handlers.onError?.(o.error||"signal-error"):(r=!0,clearTimeout(s),this.closed=!0,n.close(),t(new Error(o.error||"signal-error")));break;default:break}},n.onerror=()=>{r||(r=!0,clearTimeout(s),t(new Error("signal-unreachable")))},n.onclose=()=>{if(this.ws!==n||this.closed||!r)return;this.handlers.onReconnecting?.();let a=Math.min(15e3,500*2**this.attempt++);setTimeout(()=>{this.closed||this.connect().catch(o=>this.handlers.onError?.(String(o.message||o)))},a)}})}clone(){return new i(this.code,this.role,this.handlers,this.url)}send(e){let t=JSON.stringify({type:"signal",code:this.code,payload:e});this.ws?.readyState===WebSocket.OPEN?this.ws.send(t):this.queue.push(t)}close(){this.closed=!0;try{this.ws?.readyState===WebSocket.OPEN&&this.ws.send(JSON.stringify({type:"leave"})),this.ws?.close(1e3,"done")}catch{}}};function Y(i){let e={candidate:i.candidate,sdpMid:i.sdpMid,sdpMLineIndex:i.sdpMLineIndex};return i.usernameFragment&&(e.usernameFragment=i.usernameFragment),e}var Ve=4*1024*1024,be=1*1024*1024,Pe=45e3,Ze=3e4,Xe=300;function g(i,e){i.readyState==="open"&&i.send(JSON.stringify(e))}function Qe(i){return i.bufferedAmount<=Ve?Promise.resolve():new Promise((e,t)=>{let r=setInterval(()=>{i.readyState!=="open"?s():i.bufferedAmount<=be&&n()},200),n=()=>{a(),e()},s=()=>{a(),t(new Error("connection-lost"))},a=()=>{clearInterval(r),i.removeEventListener("bufferedamountlow",n),i.removeEventListener("close",s)};i.addEventListener("bufferedamountlow",n),i.addEventListener("close",s)})}async function Te(i,e,t){if(e.kind==="ice"){if(!i.remoteDescription){t.push(e.candidate);return}try{await i.addIceCandidate(e.candidate)}catch{}}}var _=class{constructor(e,t,r={}){this.files=e;this.onState=t;r.password&&(this.pwSalt=O(16),this.pwKeys=M(r.password,this.pwSalt)),this.code=r.code??Q(),this.secret=r.secret??ee(),this.message=r.message,this.state={status:"starting",code:this.code,secret:this.secret,sentBytes:0,confirmedBytes:0,totalBytes:e.reduce((n,s)=>n+s.size,0),speed:0,fileStates:e.map(()=>"pending"),downloads:0,log:[],protected:!!r.password,authFailures:0},this.signal=new P(this.code,"sender",{onSignal:n=>void this.onSignal(n),onPeerJoined:()=>void this.offer(),onPeerLeft:()=>{this.attempt&&!this.attempt.open&&this.dropAttempt(this.idleStatus())},onReconnecting:()=>{this.attempt?.open||this.update({status:"reconnecting"})},onError:n=>this.update({status:"error",error:n})},r.signalUrl)}code;secret;message;pwKeys;pwSalt;authFailures=0;signal;attempt=null;pendingIce=[];state;stopped=!1;nextId=1;offerGen=0;connectTimer;update(e){this.state={...this.state,...e},this.onState(this.state)}getState(){return this.state}idleStatus(){if(this.pwKeys&&this.authFailures>=5)return"failed";let e=this.state.log[0];return e?e.result==="complete"?"complete":"failed":"waiting"}async start(){this.update({status:"starting"}),await this.signal.connect(),this.update({status:"waiting"}),await this.offer()}dropAttempt(e){clearTimeout(this.connectTimer);let t=this.attempt;if(this.attempt=null,this.pendingIce=[],t){t.finished=!0;try{t.pc.close()}catch{}}this.stopped||this.update({status:e})}record(e,t,r){if(e.finished)return;e.finished=!0;let n={id:e.id,startedAt:e.startedAt,endedAt:Date.now(),bytes:t==="complete"?this.state.totalBytes:this.state.confirmedBytes,totalBytes:this.state.totalBytes,result:t,...r?{reason:r}:{}};this.update({log:[n,...this.state.log].slice(0,50),downloads:this.state.downloads+(t==="complete"?1:0),status:t==="complete"?"complete":"failed",error:r,...t==="complete"?{confirmedBytes:this.state.totalBytes,fileStates:this.files.map(()=>"delivered")}:{fileStates:this.state.fileStates.map(s=>s==="delivered"?s:"failed")}})}fail(e,t){this.attempt===e&&(e.started&&!e.finished&&this.record(e,"failed",t),this.dropAttempt(this.idleStatus()),e.started||this.update({error:t}))}async offer(){if(this.stopped||this.attempt?.open&&!this.attempt.finished)return;let e=++this.offerGen;this.dropAttempt(this.idleStatus());let t=await G();if(e!==this.offerGen||this.stopped)return;let r=new RTCPeerConnection({iceServers:t}),n=r.createDataChannel("filenymous-p2p-v2",{ordered:!0});n.binaryType="arraybuffer",n.bufferedAmountLowThreshold=be;let s={id:this.nextId++,pc:r,channel:n,open:!1,started:!1,authed:!1,nonce:"",finished:!1,startedAt:0,lastConfirm:{at:0,bytes:0}};this.attempt=s,r.onicecandidate=o=>{o.candidate&&this.attempt===s&&this.signal.send({kind:"ice",candidate:Y(o.candidate)})},r.onconnectionstatechange=()=>{this.attempt===s&&r.connectionState==="failed"&&this.fail(s,s.open?"connection-lost":"ice-failed")},n.onopen=()=>{this.attempt===s&&(s.open=!0,clearTimeout(this.connectTimer),this.serve(s))},n.onclose=()=>{this.attempt===s&&!s.finished&&this.fail(s,s.started?"connection-lost":"recipient-left")};let a=await r.createOffer();await r.setLocalDescription(a),this.attempt===s&&this.signal.send({kind:"offer",sdp:a.sdp??""})}async onSignal(e){let t=this.attempt;if(!t)return;let r=t.pc;if(e.kind==="answer"&&r.signalingState==="have-local-offer"){this.update({status:"connecting",error:void 0}),clearTimeout(this.connectTimer),this.connectTimer=setTimeout(()=>{this.attempt===t&&!t.open&&this.fail(t,"ice-timeout")},Pe);try{await r.setRemoteDescription({type:"answer",sdp:e.sdp})}catch{this.attempt===t&&this.offer();return}for(let n of this.pendingIce.splice(0))await r.addIceCandidate(n).catch(()=>{})}else e.kind==="ice"&&await Te(r,e,this.pendingIce)}async serve(e){let{channel:t}=e;if(t.onmessage=r=>{if(typeof r.data!="string"||this.attempt!==e)return;let n=N(r.data);if(n)switch(n.type){case"start":if(this.pwKeys&&!e.authed){this.fail(e,"auth-required");break}this.stream(e);break;case"auth":this.checkAuth(e,n.proof);break;case"progress":{let s=Date.now(),a=Math.min(this.state.totalBytes,Math.max(0,Number(n.bytes)||0)),o=(s-e.lastConfirm.at)/1e3,l=o>0?(a-e.lastConfirm.bytes)/o:0;e.lastConfirm={at:s,bytes:a},this.update({confirmedBytes:a,speed:this.state.speed?this.state.speed*.7+l*.3:l});break}case"file-ack":{let s=[...this.state.fileStates];n.index>=0&&n.index<s.length&&(s[n.index]="delivered"),this.update({fileStates:s});break}case"ack":this.record(e,"complete"),setTimeout(()=>{this.attempt===e&&this.dropAttempt("complete")},1500);break;case"cancel":this.fail(e,n.reason||"cancelled");break;case"hello":this.update({peerDevice:n.device});break;default:break}},this.pwKeys){if(this.authFailures>=5){g(t,{type:"cancel",reason:"too-many-attempts"}),this.fail(e,"too-many-attempts");return}e.nonce=O(16),g(t,{type:"manifest",v:3,chunkSize:65536,files:[{name:"locked",size:0,mime:""}],wrappedSecret:"",auth:{salt:this.pwSalt,iter:31e4,nonce:e.nonce},device:C()}),this.attempt===e&&this.update({status:"connected",error:void 0});return}await this.sendManifest(e)}async checkAuth(e,t){if(!this.pwKeys||e.authed||e.finished)return;let r=typeof t=="string"&&await se(await this.pwKeys,this.code,e.nonce,t);if(this.attempt!==e)return;if(r){e.authed=!0,g(e.channel,{type:"auth-ok"}),await this.sendManifest(e);return}this.authFailures+=1;let n=Math.max(0,5-this.authFailures);this.update({authFailures:this.authFailures}),g(e.channel,{type:"auth-fail",remaining:n}),n===0&&(g(e.channel,{type:"cancel",reason:"too-many-attempts"}),this.fail(e,"too-many-attempts"),setTimeout(()=>{this.stopped=!0,this.signal.close(),this.update({status:"failed",error:"too-many-attempts"})},1e3))}async sendManifest(e){let{channel:t}=e;g(t,{type:"manifest",v:2,chunkSize:65536,files:this.files.map(r=>({name:r.name,size:r.size,mime:r.type||"application/octet-stream"})),wrappedSecret:await oe(this.code,this.secret),...this.message?{message:this.message}:{},device:C(),...this.pwKeys?{protected:!0}:{}}),this.attempt===e&&this.update({status:"connected",error:void 0})}async stream(e){if(e.started)return;e.started=!0,e.startedAt=Date.now(),e.lastConfirm={at:e.startedAt,bytes:0};let{channel:t}=e;this.update({status:"transferring",sentBytes:0,confirmedBytes:0,speed:0,fileStates:this.files.map(()=>"pending"),error:void 0});try{let r=await F(this.secret,this.pwKeys?(await this.pwKeys).mix:void 0),n=0,s=0;for(let a=0;a<this.files.length;a++){let o=this.files[a],l=[...this.state.fileStates];l[a]="sending",this.update({fileStates:l});let f=U(o.size);for(let m=0;m<f;m++){if(this.attempt!==e||t.readyState!=="open")throw new Error("connection-lost");let y=new Uint8Array(await o.slice(m*65536,(m+1)*65536).arrayBuffer());await Qe(t),t.send(await de(r,a,m,y)),n+=y.byteLength,(Date.now()-s>150||n===this.state.totalBytes)&&(s=Date.now(),this.update({sentBytes:n}))}g(t,{type:"file-end",index:a,chunks:f})}g(t,{type:"done"}),this.update({sentBytes:n})}catch(r){this.fail(e,String(r.message||r))}}stop(){this.stopped=!0;let e=this.attempt;if(e?.started&&!e.finished&&this.record(e,"failed","sender-stopped"),clearTimeout(this.connectTimer),e){try{g(e.channel,{type:"cancel",reason:"sender-stopped"})}catch{}try{e.pc.close()}catch{}}this.attempt=null,this.signal.close(),this.update({status:"stopped"})}},I=class{constructor(e,t,r,n,s=Ze){this.code=e;this.secret=t;this.onState=r;this.stallTimeoutMs=s;this.signal=new P(e,"receiver",{onSignal:a=>void this.onSignal(a),onPeerLeft:()=>{!this.channel&&this.state.status!=="complete"&&this.state.status!=="error"&&this.update({status:"waiting-sender"})},onError:a=>{this.channel||this.update({status:"error",error:a})}},n)}signal;pc=null;channel=null;pendingIce=[];key=null;state={status:"connecting",files:[],receivedBytes:0,totalBytes:0,speed:0};sink=null;queue=Promise.resolve();expected={file:0,chunk:0};connectTimer;stallTimer;lastData=0;lastProgressSent=0;speedRef={at:0,bytes:0};closed=!1;offerGen=0;challenge=null;pwMix=null;update(e){this.state={...this.state,...e},this.onState(this.state)}fail(e){if(!(this.state.status==="complete"||this.state.status==="error")){if(clearInterval(this.stallTimer),this.sink?.abort(),this.channel?.readyState==="open")try{this.channel.send(JSON.stringify({type:"cancel",reason:e}))}catch{}this.update({status:"error",error:e})}}async start(){for(let e=0;!this.closed;e++)try{await this.signal.connect(),this.pc||this.update({status:"waiting-sender",error:void 0});return}catch(t){let r=String(t.message||t);if(r!=="role-taken"||e>200){this.update({status:"error",error:r});return}this.update({status:"waiting-sender",error:r}),await new Promise(n=>setTimeout(n,3e3)),this.signal=this.signal.clone()}}async onSignal(e){if(e.kind==="offer"){if(this.channel?.readyState==="open"||this.state.status==="receiving"||this.state.status==="complete")return;let t=++this.offerGen;try{this.pc?.close()}catch{}this.pc=null,this.pendingIce=[],this.channel=null;let r=await G();if(t!==this.offerGen||this.closed)return;let n=new RTCPeerConnection({iceServers:r});this.pc=n,this.update({status:"negotiating",error:void 0}),clearTimeout(this.connectTimer),this.connectTimer=setTimeout(()=>{this.pc===n&&this.state.status==="negotiating"&&this.update({status:"error",error:"ice-timeout"})},Pe),n.onicecandidate=a=>{a.candidate&&this.pc===n&&this.signal.send({kind:"ice",candidate:Y(a.candidate)})},n.onconnectionstatechange=()=>{this.pc===n&&n.connectionState==="failed"&&this.fail(this.channel?"connection-lost":"ice-failed")},n.ondatachannel=a=>this.attach(a.channel),await n.setRemoteDescription({type:"offer",sdp:e.sdp});for(let a of this.pendingIce.splice(0))await n.addIceCandidate(a).catch(()=>{});let s=await n.createAnswer();if(await n.setLocalDescription(s),t!==this.offerGen)return;this.signal.send({kind:"answer",sdp:s.sdp??""})}else e.kind==="ice"&&(this.pc?await Te(this.pc,e,this.pendingIce):this.pendingIce.push(e.candidate))}attach(e){this.channel=e,e.binaryType="arraybuffer",e.onmessage=t=>{let r=t.data;this.lastData=Date.now(),this.queue=this.queue.then(()=>this.handle(r)).catch(n=>this.fail(String(n.message||n)))},e.onclose=()=>{(this.state.status==="receiving"||this.state.status==="ready"||this.state.status==="password")&&this.fail("sender-disconnected")}}sendProgress(e=!1){let t=Date.now();if(!e&&t-this.lastProgressSent<Xe)return;this.lastProgressSent=t;let r=(t-this.speedRef.at)/1e3;if(r>=.5){let n=(this.state.receivedBytes-this.speedRef.bytes)/r;this.speedRef={at:t,bytes:this.state.receivedBytes},this.update({speed:this.state.speed?this.state.speed*.7+n*.3:n})}this.channel?.readyState==="open"&&this.channel.send(JSON.stringify({type:"progress",bytes:this.state.receivedBytes}))}async handle(e){if(this.state.status==="error")return;if(typeof e=="string"){let s=N(e);if(!s)return;if(s.type==="manifest"&&s.v===3&&s.auth)clearTimeout(this.connectTimer),this.challenge=s.auth,this.pwMix=null,this.update({status:"password",peerDevice:s.device,authChecking:!1,authRemaining:5});else if(s.type==="auth-fail")this.pwMix=null,this.update({authChecking:!1,authError:!0,authRemaining:Math.max(0,Number(s.remaining)||0)});else if(s.type==="auth-ok")this.update({authError:!1});else if(s.type==="manifest"){if(clearTimeout(this.connectTimer),s.protected&&!this.pwMix)throw new Error("auth-required");if(s.v!==2||s.chunkSize!==65536)throw new Error("unsupported-protocol");let a=this.secret??await ce(this.code,s.wrappedSecret);this.key=await F(a,s.protected?this.pwMix:void 0),this.update({status:"ready",authChecking:!1,authError:!1,files:s.files,message:s.message,peerDevice:s.device,totalBytes:s.files.reduce((o,l)=>o+l.size,0)}),this.channel?.send(JSON.stringify({type:"hello",device:C()}))}else if(s.type==="file-end"){let a=this.state.files[s.index];if(!a||s.index!==this.expected.file||s.chunks!==U(a.size)||this.expected.chunk!==s.chunks)throw new Error("integrity-error");s.chunks===0&&await this.sink.open(s.index,a),await this.sink.close(),this.expected={file:s.index+1,chunk:0},this.sendProgress(!0),this.channel?.send(JSON.stringify({type:"file-ack",index:s.index}))}else if(s.type==="done"){if(this.expected.file!==this.state.files.length)throw new Error("integrity-error");clearInterval(this.stallTimer),this.channel?.send(JSON.stringify({type:"ack",bytes:this.state.receivedBytes})),this.update({status:"complete",receivedBytes:this.state.totalBytes}),setTimeout(()=>this.close(),5e3)}else s.type==="cancel"&&this.fail(s.reason==="sender-stopped"?"sender-stopped":s.reason||"cancelled");return}if(!this.key||!this.sink)throw new Error("unexpected-data");let{fileIndex:t,chunkIndex:r,plain:n}=await pe(this.key,e).catch(()=>{throw new Error("decrypt-error")});if(t!==this.expected.file||r!==this.expected.chunk)throw new Error("integrity-error");r===0&&await this.sink.open(t,this.state.files[t]),await this.sink.write(n),this.expected.chunk+=1,this.state={...this.state,receivedBytes:this.state.receivedBytes+n.byteLength},this.onState(this.state),this.sendProgress()}async submitPassword(e){if(this.state.status!=="password"||!this.challenge||this.state.authChecking)return;if(!this.channel||this.channel.readyState!=="open"){this.fail("sender-disconnected");return}this.update({authChecking:!0,authError:!1});let t=this.challenge,r=await M(e,t.salt,t.iter),n=await ie(r,this.code,t.nonce);this.pwMix=r.mix,this.channel.send(JSON.stringify({type:"auth",proof:n}))}download(e){if(this.state.status!=="ready")throw new Error("not-ready");if(!this.channel||this.channel.readyState!=="open"){this.fail("sender-disconnected");return}this.sink=e,this.expected={file:0,chunk:0},this.lastData=Date.now(),this.speedRef={at:Date.now(),bytes:0},this.update({status:"receiving",receivedBytes:0,speed:0}),this.channel.send(JSON.stringify({type:"start"})),clearInterval(this.stallTimer),this.stallTimer=setInterval(()=>{if(this.state.status!=="receiving")return clearInterval(this.stallTimer);Date.now()-this.lastData>this.stallTimeoutMs&&this.fail("stalled")},1e3)}close(){this.closed=!0,clearTimeout(this.connectTimer),clearInterval(this.stallTimer),this.signal.close();try{this.pc?.close()}catch{}}};var Ee=`filenymous 0.1.0 \u2014 end-to-end encrypted P2P file sharing (no server storage)
4
+
5
+ Usage:
6
+ filenymous upload <file...> [options] share files; stays online until done
7
+ filenymous receive <link|code> [options] download a share into a directory
8
+
9
+ upload options:
10
+ --ttl <dur> how long the share stays online (default 1h; e.g. 90s, 15m, 2d)
11
+ --max-downloads <n> exit after n completed downloads (default 1)
12
+ --password <pw> require a password (prefer env FILENYMOUS_PASSWORD)
13
+ --webhook <url> POST JSON events (share.created, download.completed, share.expired)
14
+ --webhook-no-link omit link and code from webhook payloads
15
+ --allow-empty exit 0 even if the TTL expires with zero downloads (default exit 2)
16
+ --json machine-readable JSON lines on stdout
17
+ --app-url <url> web app base for links (default ${K})
18
+
19
+ receive options:
20
+ -o, --output <dir> destination directory (default .)
21
+ --password <pw> password for protected shares (or env FILENYMOUS_PASSWORD)
22
+ --timeout <dur> give up if nothing completes in time (default 30m)
23
+ --json machine-readable JSON lines on stdout
24
+
25
+ common:
26
+ --signal <wss-url> signaling relay (default ${H})
27
+ -q, --quiet no progress on stderr
28
+ -h, --help, -v, --version
29
+ `,{values:c,positionals:nt}=et({allowPositionals:!0,options:{ttl:{type:"string"},"max-downloads":{type:"string"},password:{type:"string"},webhook:{type:"string"},"webhook-no-link":{type:"boolean"},"allow-empty":{type:"boolean"},json:{type:"boolean"},"app-url":{type:"string"},signal:{type:"string"},output:{type:"string",short:"o"},timeout:{type:"string"},quiet:{type:"boolean",short:"q"},help:{type:"boolean",short:"h"},version:{type:"boolean",short:"v"}}}),h=i=>process.stderr.write(`${i}
30
+ `),D=i=>c.json&&process.stdout.write(`${JSON.stringify(i)}
31
+ `),v=0;function ke(i,e,t){if(c.quiet||!process.stderr.isTTY&&Date.now()-v<2e3||Date.now()-v<250&&e<t)return;v=Date.now();let r=t?Math.floor(e/t*100):100,n=`${i} ${r}% (${b(e)} / ${b(t)})`;process.stderr.isTTY?process.stderr.write(`\r\x1B[2K${n}`):h(n)}var L=()=>process.stderr.isTTY&&!c.quiet&&v&&process.stderr.write(`
32
+ `);async function rt(i,e){try{let t=new AbortController,r=setTimeout(()=>t.abort(),5e3),n=await fetch(i,{method:"POST",headers:{"content-type":"application/json","user-agent":"filenymous-cli/0.1.0"},body:JSON.stringify(e),signal:t.signal});clearTimeout(r),n.ok||h(`warning: webhook answered HTTP ${n.status}`)}catch(t){h(`warning: webhook failed: ${t.message}`)}}async function it(i){if(!i.length)throw new Error("upload: missing file (try: filenymous upload app.zip --ttl 1h)");let e=$(c.ttl??"1h"),t=Number(c["max-downloads"]??1);if(!Number.isInteger(t)||t<1)throw new Error("--max-downloads must be a positive integer");let r=c.password??process.env.FILENYMOUS_PASSWORD??void 0,n=[];for(let d of i)n.push(await Z(Ae(d)));let s=n.reduce((d,k)=>d+k.size,0),a=n.length===1?n[0].name:n.map(d=>d.name).join(", "),o=new Date(Date.now()+e).toISOString(),l=0,f=()=>{},m=new Promise(d=>f=d),y=new _(n,d=>{d.status==="transferring"&&ke("sending",d.confirmedBytes,d.totalBytes),d.downloads>l?(l=d.downloads,L(),v=0,c.quiet||h(`\u2713 download ${l}/${t} completed${d.peerDevice?` (${d.peerDevice})`:""}`),p("download.completed").then(()=>l>=t&&setTimeout(()=>f(0),1600))):d.status==="failed"&&d.error==="too-many-attempts"?(h("\u2717 too many wrong passwords: share locked"),p("share.failed",d.error).then(()=>f(1))):d.status==="failed"&&d.error&&!c.quiet&&(L(),v=0,h(`\u2026 transfer interrupted (${d.error}); still waiting for a recipient`))},{password:r}),E=te(c["app-url"]??process.env.FILENYMOUS_APP_URL??K,y.code,y.secret);async function p(d,k){let j={event:d,link:E,code:y.code,file:a,size:s,expiresAt:o,downloads:l,maxDownloads:t,protected:!!r,...k?{error:k}:{}};D(j),c.webhook&&await rt(c.webhook,fe(j,!!c["webhook-no-link"]))}await y.start(),c.json||process.stdout.write(`${E}
33
+ `),c.quiet||(h(`Filenymous \u2014 ${a} (${b(s)})${r?", password-protected":""}`),h(`code: ${y.code} expires: ${o} downloads: 0/${t}`),h("Keep this process running: files go directly to the recipient, nothing is stored on a server.")),await p("share.created");let A=setTimeout(()=>{p("share.expired").then(()=>f(l>0||c["allow-empty"]?0:2))},e),q=()=>f(130);process.once("SIGINT",q),process.once("SIGTERM",q);let J=await m;clearTimeout(A),J===2&&!c.quiet&&h("share expired with no download"),y.stop();for(let d of n)await d.close();return J}function st(i){let e=tt({input:process.stdin,output:process.stderr,terminal:!0});return new Promise(t=>e.question(i,r=>{e.close(),t(r)}))}async function at(i){let e=ne(i??"");if(!e)throw new Error("receive: give a Filenymous link (\u2026#p2p:123-456-ABC/secret) or a code");let t=Ae(c.output??"."),r=$(c.timeout??"30m"),n=c.password??process.env.FILENYMOUS_PASSWORD??void 0,s=new x(t),a=()=>{},o=new Promise(p=>a=p),l=!1,f=!1,m=new I(e.code,e.secret,p=>{switch(p.status){case"waiting-sender":!l&&!c.quiet&&h(`waiting for the sender of ${e.code}\u2026`),l=!0;break;case"password":if(p.authChecking)break;if(p.authError&&f&&(c.password||process.env.FILENYMOUS_PASSWORD||!process.stdin.isTTY)){h(`\u2717 wrong password (${p.authRemaining??0} attempts left)`),a(1);break}(async()=>{if(!n||p.authError){if(!process.stdin.isTTY)return h("\u2717 this share needs a password (--password or FILENYMOUS_PASSWORD)"),a(1);n=await st(p.authError?`wrong password (${p.authRemaining} left), try again: `:"password: ")}f=!0,await m.submitPassword(n)})();break;case"ready":c.quiet||h(`receiving ${p.files.length} file(s), ${b(p.totalBytes)}${p.peerDevice?` from ${p.peerDevice}`:""} \u2192 ${t}`),m.download(s);break;case"receiving":ke("receiving",p.receivedBytes,p.totalBytes);break;case"complete":L();for(let A of s.written)c.json?D({event:"file.received",path:A}):process.stdout.write(`${A}
34
+ `);D({event:"download.completed",files:s.written,size:p.totalBytes}),setTimeout(()=>a(0),5200);break;case"error":L(),h(`\u2717 ${p.error??"error"}`),D({event:"error",error:p.error}),a(1);break}}),y=setTimeout(()=>{h("\u2717 timeout"),a(1)},r);process.once("SIGINT",()=>a(130)),m.start();let E=await o;return clearTimeout(y),m.close(),E}async function ot(){if(c.version)return process.stdout.write(`0.1.0
35
+ `),0;let[i,...e]=nt;return c.help||!i?(process.stdout.write(Ee),i||c.help?0:1):(V(c.signal??process.env.FILENYMOUS_SIGNAL_URL??H),i==="upload"||i==="send"?it(e):i==="receive"||i==="get"?at(e[0]):(h(`unknown command: ${i}
36
+ `),process.stdout.write(Ee),1))}ot().then(i=>process.exit(i),i=>{h(`\u2717 ${i.message||i}`),process.exit(1)});
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "filenymous",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Ultra-light end-to-end encrypted P2P file sharing from the terminal (WebRTC, no server storage). Compatible with https://filenymous.eu/app/",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "filenymous": "dist/filenymous.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18.17"
16
+ },
17
+ "keywords": [
18
+ "p2p",
19
+ "webrtc",
20
+ "file-transfer",
21
+ "e2ee",
22
+ "ci",
23
+ "cli"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/Geoking2104/Filenymous.git",
28
+ "directory": "cli"
29
+ },
30
+ "scripts": {
31
+ "build": "node build.mjs",
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "npm run build && node --test test/"
34
+ },
35
+ "dependencies": {
36
+ "node-datachannel": "^0.33.4",
37
+ "ws": "^8.18.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^20.14.0",
41
+ "@types/ws": "^8.5.12",
42
+ "esbuild": "^0.24.0",
43
+ "typescript": "^5.6.3"
44
+ },
45
+ "homepage": "https://github.com/Geoking2104/Filenymous/tree/main/cli#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/Geoking2104/Filenymous/issues"
48
+ }
49
+ }