diff --git a/package-lock.json b/package-lock.json index f35e877e..d7b693f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,12 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "bonjour-service": "^1.4.1", "chalk": "^5.4.1", "commander": "^13.1.0", "ink": "^7.0.0", "react": "^19.2.5", + "selfsigned": "^5.5.0", "strip-ansi": "^7.2.0", "undici": "^7.27.2", "zod": "^3.25.76" @@ -24,6 +26,7 @@ "devDependencies": { "@types/node": "^22.19.17", "@types/react": "^19.2.14", + "@types/selfsigned": "^2.0.4", "tsup": "^8.4.0", "tsx": "^4.19.0", "typescript": "^5.8.0", @@ -539,6 +542,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -579,6 +588,175 @@ } } }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", + "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", + "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", + "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", + "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-rsa": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", + "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", + "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pfx": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", + "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", + "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -974,6 +1152,13 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/selfsigned": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/selfsigned/-/selfsigned-2.0.4.tgz", + "integrity": "sha512-vNmPhMatNNp9iZ/Ri2w1fciqNcPA2edA58qhzi5F/qDO49Ap4GtEGytuiTX1pSO1HJr81VopC8/INylO9s0keQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/expect": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", @@ -1194,6 +1379,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1240,6 +1439,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bonjour-service": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.1.tgz", + "integrity": "sha512-9KM4QMPKnaJqaja1v7gYO/+TXZGLtzPA05NmUTqDAJjcsWeVoOXKMvU9g0gfuuoYTQqJZ924hivICd5R/bCJbA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1265,6 +1474,15 @@ "node": ">= 0.8" } }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1563,6 +1781,18 @@ "node": ">= 0.8" } }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2372,6 +2602,19 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -2574,6 +2817,23 @@ "pathe": "^2.0.1" } }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -2659,6 +2919,24 @@ "node": ">= 0.10" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -2736,6 +3014,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2854,6 +3138,19 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/selfsigned": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", + "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", + "license": "MIT", + "dependencies": { + "@peculiar/x509": "^1.14.2", + "pkijs": "^3.3.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -3206,6 +3503,12 @@ "node": ">=0.8" } }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3293,6 +3596,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tsup": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", @@ -3366,6 +3675,24 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/type-fest": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", diff --git a/package.json b/package.json index 25052350..bc1b5772 100644 --- a/package.json +++ b/package.json @@ -49,10 +49,12 @@ "homepage": "https://github.com/getagentseal/codeburn#readme", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "bonjour-service": "^1.4.1", "chalk": "^5.4.1", "commander": "^13.1.0", "ink": "^7.0.0", "react": "^19.2.5", + "selfsigned": "^5.5.0", "strip-ansi": "^7.2.0", "undici": "^7.27.2", "zod": "^3.25.76" @@ -60,6 +62,7 @@ "devDependencies": { "@types/node": "^22.19.17", "@types/react": "^19.2.14", + "@types/selfsigned": "^2.0.4", "tsup": "^8.4.0", "tsx": "^4.19.0", "typescript": "^5.8.0", diff --git a/src/main.ts b/src/main.ts index cf920075..9f0df2ea 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,12 @@ import { buildPeriodData, buildMenubarPayloadForRange } from './usage-aggregator import { renderDashboard } from './dashboard.js' import { renderOverview } from './overview.js' import { runWebDashboard } from './web-dashboard.js' +import { hostname } from 'os' +import { runShareServer } from './sharing/share-run.js' +import { addRemote, linkRemote, pullDevices, renderDevices } from './sharing/host.js' +import { browse } from './sharing/discovery.js' +import { promptChoice } from './sharing/prompt.js' +import { loadRemotes, saveRemotes } from './sharing/store.js' import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js' import { runOptimize } from './optimize.js' import { renderCompare } from './compare.js' @@ -478,6 +484,71 @@ program await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel, daySelection?.day) }) +program + .command('share') + .description("Securely share this device's usage with your other devices on the same network") + .option('--port ', 'Port to listen on', parseInteger, 7777) + .option('--pair', 'Open a pairing window and print a PIN to add a new device') + .option('--always', 'Keep sharing until stopped (default stops after 10 min idle)') + .action(async (opts) => { + await runShareServer({ port: opts.port, pair: !!opts.pair, always: !!opts.always }) + }) + +program + .command('devices [action] [target]') + .description('Combined usage across your devices. Actions: add (find nearby & pair) | add --pin (manual) | rm ') + .option('--pin ', 'Pairing PIN shown on the device you are adding') + .option('-p, --period ', 'Period: today, week, 30days, month, all', 'month') + .option('--port ', 'Default port when adding a device', parseInteger, 7777) + .action(async (action: string | undefined, target: string | undefined, opts) => { + await loadPricing() + if (action === 'add') { + if (target && opts.pin) { + const device = await addRemote(target, opts.pin, { defaultPort: opts.port }) + console.log(`\n Paired with "${device.name}" (${device.host}:${device.port}).\n`) + return + } + process.stdout.write('\n Looking for devices on your network...\n') + const found = await browse(3000) + if (found.length === 0) { + console.error(' No devices found. On the other Mac run `codeburn share`, and make sure both are on the same Wi-Fi.\n') + process.exit(1) + } + let chosen = found[0]! + if (found.length > 1) { + found.forEach((d, i) => process.stdout.write(` ${i + 1}) ${d.name} (${d.host})\n`)) + const n = await promptChoice(' Connect to which? [number]', found.length) + if (n < 1) { + console.error(' Cancelled.\n') + process.exit(1) + } + chosen = found[n - 1]! + } + const device = await linkRemote(chosen, { + onCode: (code) => + process.stdout.write(`\n Connecting to "${chosen.name}". Confirm this code on that device: ${code}\n Waiting for approval...\n`), + }) + console.log(`\n Paired with "${device.name}".\n`) + return + } + if (action === 'rm' || action === 'remove') { + const remotes = await loadRemotes() + const next = remotes.filter((r) => r.name !== target && `${r.host}:${r.port}` !== target) + await saveRemotes(next) + console.log(`\n Removed ${remotes.length - next.length} device(s).\n`) + return + } + const localGetUsage = async (q: { period?: string; from?: string; to?: string }) => { + const customRange = parseDateRangeFlags(q.from, q.to) + const periodInfo = customRange + ? { range: customRange, label: formatDateRangeLabel(q.from, q.to) } + : getDateRange(toPeriod(q.period ?? opts.period)) + return buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false }) + } + const results = await pullDevices(localGetUsage, { period: opts.period }, hostname(), {}) + process.stdout.write('\n' + renderDevices(results)) + }) + program .command('overview') .description('Plain-text usage overview, copy-pasteable (defaults to this month)') diff --git a/src/sharing/client.ts b/src/sharing/client.ts new file mode 100644 index 00000000..60d2886e --- /dev/null +++ b/src/sharing/client.ts @@ -0,0 +1,89 @@ +import { request } from 'https' +import type { TLSSocket } from 'tls' + +import { certFingerprint } from './pairing.js' +import type { Identity } from './identity.js' +import type { UsageQuery } from './share-server.js' + +export type PeerEndpoint = { + identity: Identity // our own identity (we present our cert so the peer can bind a token to us) + host: string + port: number + // When set, the connection is aborted unless the peer's cert fingerprint matches. + expectedFingerprint?: string +} + +export type Response = { status: number; serverFingerprint: string; json: unknown } + +// One request to a peer. Self-signed certs are accepted at the TLS layer +// (rejectUnauthorized:false) but the peer is authenticated by pinning its cert +// fingerprint, the SSH/Syncthing trust-on-first-use model. +function call( + ep: PeerEndpoint, + method: string, + path: string, + headers: Record = {}, + body?: string, +): Promise { + return new Promise((resolve, reject) => { + const req = request( + { + host: ep.host, + port: ep.port, + method, + path, + key: ep.identity.key, + cert: ep.identity.cert, + rejectUnauthorized: false, + checkServerIdentity: () => undefined, + headers: { ...headers, ...(body ? { 'content-type': 'application/json' } : {}) }, + }, + (res) => { + const cert = (res.socket as TLSSocket).getPeerCertificate?.() + const serverFingerprint = cert?.raw ? certFingerprint(cert.raw) : '' + if (ep.expectedFingerprint && serverFingerprint !== ep.expectedFingerprint) { + res.destroy() + reject(new Error('server fingerprint mismatch')) + return + } + let data = '' + res.on('data', (chunk) => { + data += chunk + }) + res.on('end', () => resolve({ status: res.statusCode ?? 0, serverFingerprint, json: safeJson(data) })) + }, + ) + req.on('error', reject) + if (body) req.write(body) + req.end() + }) +} + +export function hello(ep: PeerEndpoint): Promise { + return call(ep, 'GET', '/api/peer/hello') +} + +export function pair(ep: PeerEndpoint, pin: string, name: string): Promise { + return call(ep, 'POST', '/api/peer/pair', {}, JSON.stringify({ pin, name })) +} + +// Approve-style pairing: no PIN. The peer prompts its user to approve; this +// request stays open until they accept or decline. +export function pairRequest(ep: PeerEndpoint, name: string): Promise { + return call(ep, 'POST', '/api/peer/pair-request', {}, JSON.stringify({ name })) +} + +export function fetchUsage(ep: PeerEndpoint, token: string, query: UsageQuery = {}): Promise { + const params = new URLSearchParams() + for (const [k, v] of Object.entries(query)) if (v) params.set(k, v) + const qs = params.toString() + return call(ep, 'GET', `/api/usage${qs ? `?${qs}` : ''}`, { authorization: `Bearer ${token}` }) +} + +function safeJson(s: string): unknown { + try { + return JSON.parse(s) + } catch { + return null + } +} diff --git a/src/sharing/discovery.ts b/src/sharing/discovery.ts new file mode 100644 index 00000000..9e348742 --- /dev/null +++ b/src/sharing/discovery.ts @@ -0,0 +1,54 @@ +import { Bonjour, type Service } from 'bonjour-service' + +const SERVICE_TYPE = 'codeburn' + +export type DiscoveredDevice = { name: string; host: string; port: number; fingerprint: string } + +export type Advertiser = { stop: () => Promise } + +// Announce this device on the local network so others can find it without an IP. +export function advertise(opts: { name: string; port: number; fingerprint: string }): Advertiser { + const bonjour = new Bonjour() + bonjour.publish({ + name: opts.name, + type: SERVICE_TYPE, + port: opts.port, + txt: { fp: opts.fingerprint, dn: opts.name, v: '1' }, + }) + return { + stop: () => + new Promise((resolve) => { + bonjour.unpublishAll(() => bonjour.destroy(() => resolve())) + }), + } +} + +function pickAddress(service: Service): string | null { + const addrs = service.addresses ?? [] + const ipv4 = addrs.find((a) => /^\d+\.\d+\.\d+\.\d+$/.test(a)) + if (ipv4) return ipv4 + if (service.host) return service.host + return addrs[0] ?? null +} + +// Browse the local network for sharing devices for `timeoutMs`. Resolves to the +// devices found, deduped by fingerprint. +export function browse(timeoutMs = 2500): Promise { + return new Promise((resolve) => { + const bonjour = new Bonjour() + const found = new Map() + const browser = bonjour.find({ type: SERVICE_TYPE }, (service) => { + const txt = (service.txt ?? {}) as Record + const fingerprint = txt['fp'] + const address = pickAddress(service) + if (!fingerprint || !address) return + const name = txt['dn'] || service.name || address + found.set(fingerprint, { name, host: address, port: service.port, fingerprint }) + }) + const timer = setTimeout(() => { + browser.stop() + bonjour.destroy(() => resolve([...found.values()])) + }, timeoutMs) + timer.unref?.() + }) +} diff --git a/src/sharing/host.ts b/src/sharing/host.ts new file mode 100644 index 00000000..158782b4 --- /dev/null +++ b/src/sharing/host.ts @@ -0,0 +1,137 @@ +import { hello, pair, pairRequest, fetchUsage } from './client.js' +import { loadOrCreateIdentity } from './identity.js' +import { pairingCode } from './pairing.js' +import type { DiscoveredDevice } from './discovery.js' +import type { UsageQuery } from './share-server.js' +import { getSharingDir, loadRemotes, saveRemotes, type RemoteDevice } from './store.js' +import { formatCost } from '../currency.js' +import { formatTokens } from '../format.js' + +// Minimal shape we read from a device's usage payload (the menubar payload). +type DevicePayload = { + current?: { cost?: number; calls?: number; sessions?: number; inputTokens?: number; outputTokens?: number } +} + +export type DeviceUsage = { + name: string + local: boolean + payload?: DevicePayload + error?: string +} + +function parseHostPort(input: string, defaultPort: number): { host: string; port: number } { + const idx = input.lastIndexOf(':') + if (idx > 0 && /^\d+$/.test(input.slice(idx + 1))) { + return { host: input.slice(0, idx), port: Number(input.slice(idx + 1)) } + } + return { host: input, port: defaultPort } +} + +// Pair with a device the user is currently sharing (PIN shown on that device), +// pin its fingerprint, store the issued token, and persist it. +export async function addRemote( + input: string, + pin: string, + opts: { defaultPort: number; dir?: string }, +): Promise { + const dir = opts.dir ?? getSharingDir() + const identity = await loadOrCreateIdentity(dir) + const { host, port } = parseHostPort(input, opts.defaultPort) + + const h = await hello({ identity, host, port }) + if (h.status !== 200) throw new Error(`could not reach a CodeBurn device at ${host}:${port}`) + const info = h.json as { fingerprint: string; name: string } + + const pr = await pair({ identity, host, port, expectedFingerprint: info.fingerprint }, pin, identity.name) + if (pr.status !== 200) { + const err = (pr.json as { error?: string })?.error ?? `HTTP ${pr.status}` + throw new Error(`pairing failed: ${err}`) + } + const token = (pr.json as { token: string }).token + + const device: RemoteDevice = { name: info.name, host, port, fingerprint: info.fingerprint, token, addedAt: Date.now() } + const remotes = (await loadRemotes(dir)).filter((r) => r.fingerprint !== device.fingerprint) + remotes.push(device) + await saveRemotes(remotes, dir) + return device +} + +// Pair with a discovered device using approve-style pairing (no PIN). The owner +// of that device approves on their screen after confirming the matching code. +export async function linkRemote( + d: DiscoveredDevice, + opts: { dir?: string; onCode?: (code: string) => void } = {}, +): Promise { + const dir = opts.dir ?? getSharingDir() + const identity = await loadOrCreateIdentity(dir) + const code = pairingCode(identity.fingerprint, d.fingerprint) + opts.onCode?.(code) + const r = await pairRequest({ identity, host: d.host, port: d.port, expectedFingerprint: d.fingerprint }, identity.name) + if (r.status !== 200) { + throw new Error(r.status === 403 ? 'the other device declined' : `pairing failed (HTTP ${r.status})`) + } + const token = (r.json as { token: string }).token + const device: RemoteDevice = { name: d.name, host: d.host, port: d.port, fingerprint: d.fingerprint, token, addedAt: Date.now() } + const remotes = (await loadRemotes(dir)).filter((x) => x.fingerprint !== device.fingerprint) + remotes.push(device) + await saveRemotes(remotes, dir) + return device +} + +// Pull this machine's usage plus every paired remote's, each kept separate. +export async function pullDevices( + localGetUsage: (q: UsageQuery) => Promise, + query: UsageQuery, + localName: string, + opts: { dir?: string } = {}, +): Promise { + const dir = opts.dir ?? getSharingDir() + const identity = await loadOrCreateIdentity(dir) + const remotes = await loadRemotes(dir) + + const results: DeviceUsage[] = [{ name: localName, local: true, payload: await localGetUsage(query) }] + for (const r of remotes) { + try { + const res = await fetchUsage({ identity, host: r.host, port: r.port, expectedFingerprint: r.fingerprint }, r.token, query) + if (res.status === 200) results.push({ name: r.name, local: false, payload: res.json as DevicePayload }) + else results.push({ name: r.name, local: false, error: res.status === 401 ? 'not authorized (re-pair?)' : `HTTP ${res.status}` }) + } catch (e) { + results.push({ name: r.name, local: false, error: e instanceof Error ? e.message : String(e) }) + } + } + return results +} + +export function renderDevices(results: DeviceUsage[]): string { + const num = (n: number | undefined): number => n ?? 0 + const rows = results.map((d) => { + const c = d.payload?.current + return { + name: d.name + (d.local ? ' (this Mac)' : ''), + cost: num(c?.cost), + tokens: num(c?.inputTokens) + num(c?.outputTokens), + calls: num(c?.calls), + sessions: num(c?.sessions), + error: d.error, + } + }) + const combined = rows.reduce( + (a, r) => ({ cost: a.cost + r.cost, tokens: a.tokens + r.tokens, calls: a.calls + r.calls, sessions: a.sessions + r.sessions }), + { cost: 0, tokens: 0, calls: 0, sessions: 0 }, + ) + + const nameW = Math.max(8, ...rows.map((r) => r.name.length), 'Combined'.length) + const line = (name: string, cost: string, tokens: string, calls: string): string => + ` ${name.padEnd(nameW)} ${cost.padStart(11)} ${tokens.padStart(9)} ${calls.padStart(8)}` + + const out: string[] = [] + out.push(line('Device', 'Cost', 'Tokens', 'Calls')) + out.push(' ' + '-'.repeat(nameW + 11 + 9 + 8 + 6)) + for (const r of rows) { + if (r.error) out.push(line(r.name, '-', '-', r.error)) + else out.push(line(r.name, formatCost(r.cost), formatTokens(r.tokens), r.calls.toLocaleString())) + } + out.push(' ' + '-'.repeat(nameW + 11 + 9 + 8 + 6)) + out.push(line('Combined', formatCost(combined.cost), formatTokens(combined.tokens), combined.calls.toLocaleString())) + return out.join('\n') + '\n' +} diff --git a/src/sharing/identity.ts b/src/sharing/identity.ts new file mode 100644 index 00000000..0ae73996 --- /dev/null +++ b/src/sharing/identity.ts @@ -0,0 +1,58 @@ +import * as selfsigned from 'selfsigned' +import { X509Certificate } from 'crypto' +import { readFile, writeFile, mkdir } from 'fs/promises' +import { existsSync } from 'fs' +import { join } from 'path' +import { hostname } from 'os' + +import { certFingerprint } from './pairing.js' + +// A device's stable identity: a self-signed TLS keypair whose certificate +// fingerprint is the trust anchor (trust-on-first-use). No CA. +export type Identity = { + key: string // private key PEM + cert: string // certificate PEM + fingerprint: string // SHA-256 hex of the certificate DER + name: string // human label (defaults to the hostname) +} + +export async function generateIdentity(name: string = hostname()): Promise { + const attrs = [{ name: 'commonName', value: 'codeburn-device' }] + // @types/selfsigned is missing `days`; the runtime accepts it. selfsigned >=5 + // resolves a Promise of { private, public, cert, fingerprint }. + const genOpts = { days: 3650, keySize: 2048, algorithm: 'sha256' } as unknown as Parameters< + typeof selfsigned.generate + >[1] + const pems = (await (selfsigned.generate(attrs, genOpts) as unknown as Promise<{ private: string; cert: string }>)) + const der = new X509Certificate(pems.cert).raw + return { key: pems.private, cert: pems.cert, fingerprint: certFingerprint(der), name } +} + +// Load the device identity from `dir`, creating and persisting it on first run. +export async function loadOrCreateIdentity(dir: string, name?: string): Promise { + const keyPath = join(dir, 'device-key.pem') + const certPath = join(dir, 'device-cert.pem') + const namePath = join(dir, 'device-name') + + if (existsSync(keyPath) && existsSync(certPath)) { + const [key, cert] = await Promise.all([readFile(keyPath, 'utf8'), readFile(certPath, 'utf8')]) + let resolvedName = name ?? hostname() + try { + const stored = (await readFile(namePath, 'utf8')).trim() + if (stored) resolvedName = name ?? stored + } catch { + /* no stored name yet */ + } + const der = new X509Certificate(cert).raw + return { key, cert, fingerprint: certFingerprint(der), name: resolvedName } + } + + const id = await generateIdentity(name) + await mkdir(dir, { recursive: true }) + await Promise.all([ + writeFile(keyPath, id.key, { mode: 0o600 }), + writeFile(certPath, id.cert), + writeFile(namePath, id.name), + ]) + return id +} diff --git a/src/sharing/pairing.ts b/src/sharing/pairing.ts new file mode 100644 index 00000000..faec7778 --- /dev/null +++ b/src/sharing/pairing.ts @@ -0,0 +1,105 @@ +import { randomBytes, createHash, timingSafeEqual } from 'crypto' + +// Device identity is the SHA-256 of its self-signed TLS certificate +// (trust-on-first-use, like SSH/Syncthing). No certificate authority involved: +// once two devices have each other's fingerprint, that pin is the trust anchor. +export function certFingerprint(cert: Buffer | string): string { + const buf = typeof cert === 'string' ? Buffer.from(cert) : cert + return createHash('sha256').update(buf).digest('hex') +} + +// Short, human-typed pairing PIN: 6 uniform digits. Rejection-sampled so the +// distribution is even (no modulo bias across 0..999999). +export function generatePin(): string { + const limit = Math.floor(0xffffffff / 1_000_000) * 1_000_000 + let n = randomBytes(4).readUInt32BE(0) + while (n >= limit) n = randomBytes(4).readUInt32BE(0) + return (n % 1_000_000).toString().padStart(6, '0') +} + +export function constantTimeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a) + const bb = Buffer.from(b) + if (ab.length !== bb.length) return false + return timingSafeEqual(ab, bb) +} + +export function mintToken(): string { + return randomBytes(32).toString('base64url') +} + +// Short confirmation code shown on BOTH devices during an approve-style pairing. +// Derived from the two cert fingerprints, so a man-in-the-middle (whose cert +// differs) yields a different code; the user confirms the codes match. This is +// the Bluetooth/SAS "do these numbers match?" check, not a secret. +export function pairingCode(fingerprintA: string, fingerprintB: string): string { + const [lo, hi] = [fingerprintA, fingerprintB].sort() + const digest = createHash('sha256').update(`${lo}|${hi}`).digest() + return (digest.readUInt16BE(0) % 1000).toString().padStart(3, '0') +} + +// An open pairing window on the device being added: a one-time PIN that expires. +// `now` is injectable so the lifecycle is deterministic in tests. +export class PairingWindow { + readonly pin: string + readonly openedAt: number + private used = false + + constructor(ttlMs = 60_000, now: number = Date.now(), pin: string = generatePin()) { + this.ttlMs = ttlMs + this.pin = pin + this.openedAt = now + } + + private readonly ttlMs: number + + isOpen(now: number = Date.now()): boolean { + return !this.used && now - this.openedAt <= this.ttlMs + } + + // Verify a submitted PIN. A correct match consumes the window (one-time use). + verify(pin: string, now: number = Date.now()): boolean { + if (!this.isOpen(now)) return false + if (!constantTimeEqual(pin, this.pin)) return false + this.used = true + return true + } +} + +export type PairedPeer = { + fingerprint: string + name: string + token: string + pairedAt: number +} + +// The devices this device trusts. A pull is authorized only when BOTH the +// bearer token AND the TLS peer fingerprint match the same paired peer, so a +// token stolen and replayed from a different device is useless on its own. +export class PeerStore { + private byFingerprint = new Map() + + constructor(peers: PairedPeer[] = []) { + for (const p of peers) this.byFingerprint.set(p.fingerprint, p) + } + + list(): PairedPeer[] { + return [...this.byFingerprint.values()] + } + + pair(fingerprint: string, name: string, now: number = Date.now()): PairedPeer { + const peer: PairedPeer = { fingerprint, name, token: mintToken(), pairedAt: now } + this.byFingerprint.set(fingerprint, peer) + return peer + } + + authorize(token: string, fingerprint: string): boolean { + const peer = this.byFingerprint.get(fingerprint) + if (!peer) return false + return constantTimeEqual(token, peer.token) + } + + unpair(fingerprint: string): boolean { + return this.byFingerprint.delete(fingerprint) + } +} diff --git a/src/sharing/prompt.ts b/src/sharing/prompt.ts new file mode 100644 index 00000000..4f58a309 --- /dev/null +++ b/src/sharing/prompt.ts @@ -0,0 +1,30 @@ +import { createInterface } from 'readline' + +export function promptYesNo(question: string, timeoutMs?: number): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + return new Promise((resolve) => { + let settled = false + const finish = (value: boolean): void => { + if (settled) return + settled = true + rl.close() + resolve(value) + } + if (timeoutMs) { + const t = setTimeout(() => finish(false), timeoutMs) + t.unref?.() + } + rl.question(`${question} [Y/n] `, (answer) => finish(!/^\s*n/i.test(answer))) + }) +} + +export function promptChoice(question: string, max: number): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + return new Promise((resolve) => { + rl.question(`${question} `, (answer) => { + rl.close() + const n = Number.parseInt(answer.trim(), 10) + resolve(Number.isInteger(n) && n >= 1 && n <= max ? n : -1) + }) + }) +} diff --git a/src/sharing/sanitize.ts b/src/sharing/sanitize.ts new file mode 100644 index 00000000..d0b77cd9 --- /dev/null +++ b/src/sharing/sanitize.ts @@ -0,0 +1,16 @@ +import type { MenubarPayload } from '../menubar-json.js' + +// Strip identifying detail before usage leaves the device. We share aggregate +// numbers (cost, tokens, models, tools, activities, daily) but never project +// names, paths, or per-session detail, so "what you are working on" stays on +// the machine that produced it. Only the totals travel. +export function sanitizeForSharing(payload: MenubarPayload): MenubarPayload { + return { + ...payload, + current: { + ...payload.current, + topProjects: [], + topSessions: [], + }, + } +} diff --git a/src/sharing/share-run.ts b/src/sharing/share-run.ts new file mode 100644 index 00000000..f048b394 --- /dev/null +++ b/src/sharing/share-run.ts @@ -0,0 +1,95 @@ +import { networkInterfaces } from 'os' + +import { loadOrCreateIdentity } from './identity.js' +import { PeerStore } from './pairing.js' +import { ShareServer, type UsageQuery } from './share-server.js' +import { advertise } from './discovery.js' +import { promptYesNo } from './prompt.js' +import { sanitizeForSharing } from './sanitize.js' +import { getSharingDir, loadPeers, savePeers } from './store.js' +import { loadPricing } from '../models.js' +import { buildMenubarPayloadForRange } from '../usage-aggregator.js' +import { getDateRange, parseDateRangeFlags, formatDateRangeLabel, toPeriod } from '../cli-date.js' + +function lanAddress(): string | null { + for (const list of Object.values(networkInterfaces())) { + for (const ni of list ?? []) { + if (ni.family === 'IPv4' && !ni.internal) return ni.address + } + } + return null +} + +const IDLE_TIMEOUT_MS = 10 * 60_000 + +// Run the secure share server. On-demand by default: it stops after 10 minutes +// of no requests. `--always` keeps it up until Ctrl+C (the opt-in persistent +// mode). `--pair` opens a one-time pairing window and prints the PIN + command. +export async function runShareServer(opts: { port: number; pair: boolean; always: boolean }): Promise { + await loadPricing() + const dir = getSharingDir() + const identity = await loadOrCreateIdentity(dir) + const peers = new PeerStore(await loadPeers(dir)) + + const getUsage = async (q: UsageQuery): Promise => { + const customRange = parseDateRangeFlags(q.from, q.to) + const periodInfo = customRange + ? { range: customRange, label: formatDateRangeLabel(q.from, q.to) } + : getDateRange(toPeriod(q.period ?? 'month')) + return sanitizeForSharing(await buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false })) + } + + const server = new ShareServer({ + identity, + peers, + getUsage, + onPaired: () => { + void savePeers(peers.list(), dir) + }, + approve: async (req) => { + process.stdout.write(`\n "${req.name}" wants your usage.\n`) + process.stdout.write(` Confirm this code matches on that device: ${req.code}\n`) + const ok = await promptYesNo(' Approve?', 60_000) + process.stdout.write(ok ? ` Approved "${req.name}".\n\n` : ` Declined "${req.name}".\n\n`) + return ok + }, + }) + + const port = await server.listen(opts.port, '0.0.0.0') + const ip = lanAddress() ?? '127.0.0.1' + const ad = advertise({ name: identity.name, port, fingerprint: identity.fingerprint }) + + const shutdown = async (): Promise => { + await ad.stop().catch(() => {}) + await server.close().catch(() => {}) + process.exit(0) + } + process.on('SIGINT', () => void shutdown()) + + process.stdout.write(`\n Sharing "${identity.name}" - discoverable on your network.\n`) + process.stdout.write(` On your other Mac, run: codeburn devices add\n`) + if (opts.pair) { + const pin = server.openPairing(120_000) + process.stdout.write(`\n Manual fallback (if discovery is blocked):\n`) + process.stdout.write(` codeburn devices add ${ip}:${port} --pin ${pin}\n`) + } + process.stdout.write(`\n ${peers.list().length} paired device(s). Press Ctrl+C to stop.\n\n`) + + if (!opts.always) { + let last = Date.now() + server.server.on('request', () => { + last = Date.now() + }) + const timer = setInterval(() => { + if (Date.now() - last > IDLE_TIMEOUT_MS) { + process.stdout.write('\n Idle, stopping share. Run `codeburn share` again when you need it.\n') + process.exit(0) + } + }, 30_000) + timer.unref() + } + + await new Promise(() => { + /* run until interrupted */ + }) +} diff --git a/src/sharing/share-server.ts b/src/sharing/share-server.ts new file mode 100644 index 00000000..bc6c9899 --- /dev/null +++ b/src/sharing/share-server.ts @@ -0,0 +1,169 @@ +import { createServer, type Server } from 'https' +import type { IncomingMessage, ServerResponse } from 'http' +import type { TLSSocket } from 'tls' +import type { AddressInfo } from 'net' + +import { certFingerprint, pairingCode, PeerStore, PairingWindow } from './pairing.js' +import type { Identity } from './identity.js' + +export type UsageQuery = { period?: string; from?: string; to?: string } + +// An approve-style pairing request, surfaced to the user on the sharing device. +export type PairRequest = { name: string; fingerprint: string; code: string } + +export type ShareServerOptions = { + identity: Identity + peers: PeerStore + getUsage: (query: UsageQuery) => Promise + // Called after a successful pairing so the caller can persist the peer list. + onPaired?: () => void + // Enables the interactive approve flow (POST /api/peer/pair-request): return + // true to accept. The user confirms the matching `code` shown on both devices. + approve?: (req: PairRequest) => Promise +} + +// A device's HTTPS sharing endpoint. Mutual TLS: the server presents its own +// self-signed cert (clients pin its fingerprint) and requests the client's cert +// so it can bind tokens to the caller's fingerprint. A pull is served only when +// the bearer token AND the client cert fingerprint match the same paired peer. +export class ShareServer { + readonly server: Server + private pairing: PairingWindow | null = null + + constructor(private readonly opts: ShareServerOptions) { + this.server = createServer( + { key: opts.identity.key, cert: opts.identity.cert, requestCert: true, rejectUnauthorized: false }, + (req, res) => { + void this.handle(req, res) + }, + ) + } + + // Open a one-time pairing window and return the PIN to show the user. + openPairing(ttlMs = 60_000): string { + this.pairing = new PairingWindow(ttlMs) + return this.pairing.pin + } + + closePairing(): void { + this.pairing = null + } + + listen(port: number, host = '0.0.0.0'): Promise { + return new Promise((resolve, reject) => { + this.server.once('error', reject) + this.server.listen(port, host, () => resolve((this.server.address() as AddressInfo).port)) + }) + } + + close(): Promise { + return new Promise((resolve) => this.server.close(() => resolve())) + } + + private clientFingerprint(req: IncomingMessage): string | null { + const cert = (req.socket as TLSSocket).getPeerCertificate?.() + if (!cert || !cert.raw) return null + return certFingerprint(cert.raw) + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? '/', 'https://localhost') + const json = (code: number, body: unknown): void => { + res.writeHead(code, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } + + // Unauthenticated: just enough for a joiner to learn who this is and whether + // pairing is currently open. No usage data here. + if (url.pathname === '/api/peer/hello' && req.method === 'GET') { + json(200, { + fingerprint: this.opts.identity.fingerprint, + name: this.opts.identity.name, + pairingOpen: !!this.pairing?.isOpen(), + }) + return + } + + if (url.pathname === '/api/peer/pair' && req.method === 'POST') { + const clientFp = this.clientFingerprint(req) + if (!clientFp) { + json(400, { error: 'client certificate required' }) + return + } + const body = safeJson(await readBody(req)) as { pin?: unknown; name?: unknown } | null + const pin = typeof body?.pin === 'string' ? body.pin : '' + const name = typeof body?.name === 'string' ? body.name : 'device' + if (!this.pairing || !this.pairing.verify(pin)) { + json(401, { error: 'invalid or expired PIN' }) + return + } + this.pairing = null + const peer = this.opts.peers.pair(clientFp, name) + this.opts.onPaired?.() + json(200, { token: peer.token, name: this.opts.identity.name, fingerprint: this.opts.identity.fingerprint }) + return + } + + if (url.pathname === '/api/peer/pair-request' && req.method === 'POST') { + const clientFp = this.clientFingerprint(req) + if (!clientFp) { + json(400, { error: 'client certificate required' }) + return + } + if (!this.opts.approve) { + json(403, { error: 'this device is not accepting new pairings' }) + return + } + const body = safeJson(await readBody(req)) as { name?: unknown } | null + const name = typeof body?.name === 'string' ? body.name : 'device' + const code = pairingCode(this.opts.identity.fingerprint, clientFp) + const approved = await this.opts.approve({ name, fingerprint: clientFp, code }) + if (!approved) { + json(403, { error: 'pairing declined' }) + return + } + const peer = this.opts.peers.pair(clientFp, name) + this.opts.onPaired?.() + json(200, { token: peer.token, name: this.opts.identity.name, fingerprint: this.opts.identity.fingerprint, code }) + return + } + + if (url.pathname === '/api/usage' && req.method === 'GET') { + const clientFp = this.clientFingerprint(req) + const token = (req.headers['authorization'] ?? '').replace(/^Bearer\s+/i, '') + if (!clientFp || !token || !this.opts.peers.authorize(token, clientFp)) { + json(401, { error: 'unauthorized' }) + return + } + const payload = await this.opts.getUsage({ + period: url.searchParams.get('period') ?? undefined, + from: url.searchParams.get('from') ?? undefined, + to: url.searchParams.get('to') ?? undefined, + }) + json(200, payload) + return + } + + json(404, { error: 'not found' }) + } +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve) => { + let data = '' + req.on('data', (chunk) => { + data += chunk + if (data.length > 1_000_000) req.destroy() // guard against oversized bodies + }) + req.on('end', () => resolve(data)) + req.on('error', () => resolve(data)) + }) +} + +function safeJson(s: string): unknown { + try { + return JSON.parse(s) + } catch { + return null + } +} diff --git a/src/sharing/store.ts b/src/sharing/store.ts new file mode 100644 index 00000000..717222e7 --- /dev/null +++ b/src/sharing/store.ts @@ -0,0 +1,50 @@ +import { readFile, writeFile, mkdir } from 'fs/promises' +import { join, dirname } from 'path' + +import { getConfigFilePath } from '../config.js' +import type { PairedPeer } from './pairing.js' + +// A device this host can pull FROM: its address, the pinned server-cert +// fingerprint, and the token issued to us during pairing. +export type RemoteDevice = { + name: string + host: string + port: number + fingerprint: string + token: string + addedAt: number +} + +// Sharing state lives next to the main config file. +export function getSharingDir(): string { + return join(dirname(getConfigFilePath()), 'sharing') +} + +async function readJson(path: string, fallback: T): Promise { + try { + return JSON.parse(await readFile(path, 'utf8')) as T + } catch { + return fallback + } +} + +async function writeJson(path: string, data: unknown): Promise { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, JSON.stringify(data, null, 2)) +} + +// Peers allowed to pull from this device (the sharing side, used by ShareServer). +export function loadPeers(dir: string = getSharingDir()): Promise { + return readJson(join(dir, 'paired-peers.json'), [] as PairedPeer[]) +} +export function savePeers(peers: PairedPeer[], dir: string = getSharingDir()): Promise { + return writeJson(join(dir, 'paired-peers.json'), peers) +} + +// Devices this host pulls from (the host side, used by `codeburn devices`). +export function loadRemotes(dir: string = getSharingDir()): Promise { + return readJson(join(dir, 'remote-devices.json'), [] as RemoteDevice[]) +} +export function saveRemotes(remotes: RemoteDevice[], dir: string = getSharingDir()): Promise { + return writeJson(join(dir, 'remote-devices.json'), remotes) +} diff --git a/tests/sharing/approve.test.ts b/tests/sharing/approve.test.ts new file mode 100644 index 00000000..b8872ec5 --- /dev/null +++ b/tests/sharing/approve.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' + +import { generateIdentity, type Identity } from '../../src/sharing/identity.js' +import { PeerStore, pairingCode } from '../../src/sharing/pairing.js' +import { ShareServer } from '../../src/sharing/share-server.js' +import { pairRequest, fetchUsage } from '../../src/sharing/client.js' + +describe('pairingCode', () => { + it('is order-independent, deterministic, and 3 digits', () => { + expect(pairingCode('aaa', 'bbb')).toBe(pairingCode('bbb', 'aaa')) + expect(pairingCode('aaa', 'bbb')).toMatch(/^\d{3}$/) + expect(pairingCode('aaa', 'bbb')).toBe(pairingCode('aaa', 'bbb')) + }) +}) + +describe('approve-style pairing (no PIN)', () => { + let server: ShareServer + let serverId: Identity + let clientId: Identity + let port: number + let seenCode = '' + + beforeAll(async () => { + serverId = await generateIdentity('MacBook') + clientId = await generateIdentity('Mac Studio') + server = new ShareServer({ + identity: serverId, + peers: new PeerStore(), + getUsage: async () => ({ current: { cost: 7 } }), + approve: async (req) => { + seenCode = req.code + return req.name !== 'Intruder' + }, + }) + port = await server.listen(0, '127.0.0.1') + }) + + afterAll(async () => { + await server.close() + }) + + const ep = () => ({ identity: clientId, host: '127.0.0.1', port, expectedFingerprint: serverId.fingerprint }) + + it('accepts an approved device, with the same code on both sides, and the token works', async () => { + const r = await pairRequest(ep(), 'Mac Studio') + expect(r.status).toBe(200) + const body = r.json as { token: string; code: string } + expect(body.token).toBeTruthy() + // Both ends derive the same confirmation code from the two fingerprints. + expect(body.code).toBe(pairingCode(serverId.fingerprint, clientId.fingerprint)) + expect(seenCode).toBe(body.code) + + const usage = await fetchUsage(ep(), body.token) + expect(usage.status).toBe(200) + expect((usage.json as { current: { cost: number } }).current.cost).toBe(7) + }) + + it('rejects a declined device', async () => { + const r = await pairRequest(ep(), 'Intruder') + expect(r.status).toBe(403) + }) +}) diff --git a/tests/sharing/host.test.ts b/tests/sharing/host.test.ts new file mode 100644 index 00000000..3d1dd188 --- /dev/null +++ b/tests/sharing/host.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { mkdtemp, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { generateIdentity } from '../../src/sharing/identity.js' +import { PeerStore } from '../../src/sharing/pairing.js' +import { ShareServer } from '../../src/sharing/share-server.js' +import { addRemote, pullDevices, renderDevices, type DeviceUsage } from '../../src/sharing/host.js' + +describe('host device flow (loopback)', () => { + let server: ShareServer + let port: number + let dir: string + const remoteUsage = { current: { cost: 100, calls: 10, sessions: 2, inputTokens: 1000, outputTokens: 200 } } + + beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), 'cb-host-')) + const serverId = await generateIdentity('MacBook') + server = new ShareServer({ identity: serverId, peers: new PeerStore(), getUsage: async () => remoteUsage }) + port = await server.listen(0, '127.0.0.1') + }) + + afterAll(async () => { + await server.close() + await rm(dir, { recursive: true, force: true }) + }) + + it('pairs, persists, pulls both devices, and combines', async () => { + const pin = server.openPairing() + const device = await addRemote(`127.0.0.1:${port}`, pin, { defaultPort: port, dir }) + expect(device.name).toBe('MacBook') + expect(device.token).toBeTruthy() + + const localUsage = { current: { cost: 50, calls: 5, sessions: 1, inputTokens: 500, outputTokens: 100 } } + const results = await pullDevices(async () => localUsage, { period: 'month' }, 'Mac Studio', { dir }) + + expect(results).toHaveLength(2) + expect(results[0]!.local).toBe(true) + expect(results[0]!.payload!.current!.cost).toBe(50) + const remote = results.find((r) => !r.local)! + expect(remote.name).toBe('MacBook') + expect(remote.payload!.current!.cost).toBe(100) + + const text = renderDevices(results) + expect(text).toContain('Mac Studio (this Mac)') + expect(text).toContain('MacBook') + expect(text).toContain('Combined') + expect(text).toContain('150') // combined cost 50 + 100 + }) + + it('renders an unreachable device as an error without dropping the combined row', () => { + const results: DeviceUsage[] = [ + { name: 'Mac Studio', local: true, payload: { current: { cost: 10, calls: 1, sessions: 1, inputTokens: 1, outputTokens: 1 } } }, + { name: 'MacBook', local: false, error: 'connection refused' }, + ] + const text = renderDevices(results) + expect(text).toContain('connection refused') + expect(text).toContain('Combined') + }) +}) diff --git a/tests/sharing/pairing.test.ts b/tests/sharing/pairing.test.ts new file mode 100644 index 00000000..5410cc9e --- /dev/null +++ b/tests/sharing/pairing.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest' + +import { + certFingerprint, + generatePin, + constantTimeEqual, + mintToken, + PairingWindow, + PeerStore, +} from '../../src/sharing/pairing.js' + +describe('certFingerprint', () => { + it('is a deterministic 64-char hex digest', () => { + const fp = certFingerprint('cert-bytes') + expect(fp).toMatch(/^[0-9a-f]{64}$/) + expect(certFingerprint('cert-bytes')).toBe(fp) + }) + it('differs for different certs', () => { + expect(certFingerprint('a')).not.toBe(certFingerprint('b')) + }) +}) + +describe('generatePin', () => { + it('is always 6 digits', () => { + for (let i = 0; i < 200; i++) expect(generatePin()).toMatch(/^\d{6}$/) + }) + it('varies', () => { + const pins = new Set(Array.from({ length: 50 }, () => generatePin())) + expect(pins.size).toBeGreaterThan(1) + }) +}) + +describe('constantTimeEqual', () => { + it('matches equal strings and rejects different ones', () => { + expect(constantTimeEqual('abc', 'abc')).toBe(true) + expect(constantTimeEqual('abc', 'abd')).toBe(false) + expect(constantTimeEqual('abc', 'abcd')).toBe(false) + }) +}) + +describe('mintToken', () => { + it('is url-safe and unique', () => { + const a = mintToken() + const b = mintToken() + expect(a).toMatch(/^[A-Za-z0-9_-]+$/) + expect(a).not.toBe(b) + }) +}) + +describe('PairingWindow', () => { + it('accepts the correct PIN within the window', () => { + const w = new PairingWindow(1000, 1000, '123456') + expect(w.verify('123456', 1500)).toBe(true) + }) + it('rejects a wrong PIN', () => { + const w = new PairingWindow(1000, 1000, '123456') + expect(w.verify('000000', 1200)).toBe(false) + }) + it('rejects after the window expires', () => { + const w = new PairingWindow(1000, 1000, '123456') + expect(w.isOpen(3000)).toBe(false) + expect(w.verify('123456', 3000)).toBe(false) + }) + it('is one-time: a consumed PIN cannot be reused', () => { + const w = new PairingWindow(10_000, 1000, '123456') + expect(w.verify('123456', 1100)).toBe(true) + expect(w.verify('123456', 1200)).toBe(false) + }) +}) + +describe('PeerStore', () => { + it('authorizes only when token AND fingerprint both match the same peer', () => { + const store = new PeerStore() + const a = store.pair('fp-aaa', 'MacBook') + const b = store.pair('fp-bbb', 'Mac Studio') + + // correct pairing + expect(store.authorize(a.token, 'fp-aaa')).toBe(true) + // right token, wrong device fingerprint -> denied (stolen-token defense) + expect(store.authorize(a.token, 'fp-bbb')).toBe(false) + // wrong token on the right device -> denied + expect(store.authorize('not-the-token', 'fp-aaa')).toBe(false) + // unknown device -> denied + expect(store.authorize(a.token, 'fp-ccc')).toBe(false) + expect(store.authorize(b.token, 'fp-bbb')).toBe(true) + }) + + it('revokes a peer on unpair', () => { + const store = new PeerStore() + const p = store.pair('fp-x', 'Laptop') + expect(store.authorize(p.token, 'fp-x')).toBe(true) + expect(store.unpair('fp-x')).toBe(true) + expect(store.authorize(p.token, 'fp-x')).toBe(false) + expect(store.list()).toHaveLength(0) + }) + + it('round-trips through serializable peer records', () => { + const store = new PeerStore() + store.pair('fp-1', 'A') + const restored = new PeerStore(store.list()) + const peer = restored.list()[0]! + expect(restored.authorize(peer.token, 'fp-1')).toBe(true) + }) +}) diff --git a/tests/sharing/sanitize.test.ts b/tests/sharing/sanitize.test.ts new file mode 100644 index 00000000..6ad7458b --- /dev/null +++ b/tests/sharing/sanitize.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' + +import { sanitizeForSharing } from '../../src/sharing/sanitize.js' +import type { MenubarPayload } from '../../src/menubar-json.js' + +function fixture(): MenubarPayload { + return { + generated: 'now', + current: { + label: 'June', + cost: 100, + calls: 5, + sessions: 2, + oneShotRate: 1, + inputTokens: 10, + outputTokens: 20, + cacheHitPercent: 90, + codexCredits: 0, + topActivities: [{ name: 'Coding', cost: 50, savingsUSD: 0, turns: 3, oneShotRate: 1 }], + topModels: [{ name: 'Opus', cost: 80, savingsUSD: 0, savingsBaselineModel: '', calls: 4 }], + providers: { claude: 100 }, + topProjects: [ + { name: 'secret-project', cost: 100, savingsUSD: 0, sessions: 2, avgCostPerSession: 50, sessionDetails: [] }, + ], + tools: [{ name: 'Bash', calls: 9 }], + topSessions: [{ project: 'secret-project', cost: 100, savingsUSD: 0, calls: 5, date: '2026-06-01' }], + }, + history: { daily: [] }, + } as unknown as MenubarPayload +} + +describe('sanitizeForSharing', () => { + it('strips project names and session detail but keeps aggregates', () => { + const clean = sanitizeForSharing(fixture()) + expect(clean.current.topProjects).toEqual([]) + expect(clean.current.topSessions).toEqual([]) + expect(clean.current.cost).toBe(100) + expect(clean.current.topModels[0]!.name).toBe('Opus') + expect(clean.current.providers).toEqual({ claude: 100 }) + }) + + it('leaks no project name anywhere in the shared payload', () => { + const clean = sanitizeForSharing(fixture()) + expect(JSON.stringify(clean)).not.toContain('secret-project') + }) +}) diff --git a/tests/sharing/transport.test.ts b/tests/sharing/transport.test.ts new file mode 100644 index 00000000..75872a5e --- /dev/null +++ b/tests/sharing/transport.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' + +import { generateIdentity, type Identity } from '../../src/sharing/identity.js' +import { PeerStore } from '../../src/sharing/pairing.js' +import { ShareServer } from '../../src/sharing/share-server.js' +import { hello, pair, fetchUsage } from '../../src/sharing/client.js' + +describe('device sharing transport (loopback mutual TLS)', () => { + let server: ShareServer + let serverId: Identity + let clientId: Identity + let peers: PeerStore + let port: number + + beforeAll(async () => { + serverId = await generateIdentity('MacBook') + clientId = await generateIdentity('Mac Studio') + peers = new PeerStore() + server = new ShareServer({ identity: serverId, peers, getUsage: async () => ({ current: { cost: 42 } }) }) + port = await server.listen(0, '127.0.0.1') + }) + + afterAll(async () => { + await server.close() + }) + + const ep = () => ({ identity: clientId, host: '127.0.0.1', port }) + + it('hello exposes name + fingerprint, and the client sees the right cert', async () => { + const r = await hello(ep()) + expect(r.status).toBe(200) + const body = r.json as { name: string; fingerprint: string } + expect(body.name).toBe('MacBook') + expect(body.fingerprint).toBe(serverId.fingerprint) + expect(r.serverFingerprint).toBe(serverId.fingerprint) + }) + + it('denies usage before pairing', async () => { + const r = await fetchUsage(ep(), 'no-token') + expect(r.status).toBe(401) + }) + + it('pairs with a valid PIN, then authorizes a pinned usage pull', async () => { + const pin = server.openPairing() + const pr = await pair(ep(), pin, 'Mac Studio') + expect(pr.status).toBe(200) + const token = (pr.json as { token: string }).token + expect(token).toBeTruthy() + + const ur = await fetchUsage({ ...ep(), expectedFingerprint: serverId.fingerprint }, token) + expect(ur.status).toBe(200) + expect((ur.json as { current: { cost: number } }).current.cost).toBe(42) + }) + + it('rejects a wrong PIN', async () => { + server.openPairing() + const pr = await pair(ep(), '000000', 'x') + expect(pr.status).toBe(401) + }) + + it('rejects a token replayed from a different device fingerprint', async () => { + const pin = server.openPairing() + const pr = await pair(ep(), pin, 'Mac Studio') + const token = (pr.json as { token: string }).token + const attacker = await generateIdentity('Evil') + const r = await fetchUsage({ identity: attacker, host: '127.0.0.1', port }, token) + expect(r.status).toBe(401) + }) + + it('aborts when the peer fingerprint does not match the pin', async () => { + await expect(hello({ ...ep(), expectedFingerprint: 'deadbeef' })).rejects.toThrow(/fingerprint mismatch/) + }) +})