diff --git a/KeeperSdk/package-lock.json b/KeeperSdk/package-lock.json index 407c2de0..f16ea895 100644 --- a/KeeperSdk/package-lock.json +++ b/KeeperSdk/package-lock.json @@ -7,10 +7,9 @@ "": { "name": "@keeper-security/keeper-sdk-javascript", "version": "1.0.0", - "license": "ISC", "dependencies": { - "@keeper-security/keeperapi": "17.1.3", - "ts-node": "^10.7.0", + "@keeper-security/keeperapi": "file:../keeperapi", + "asmcrypto.js": "^2.3.2", "typescript": "^4.6.3" }, "devDependencies": { @@ -18,47 +17,9 @@ "prettier": "^3.8.1" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@keeper-security/keeperapi": { + "../keeperapi": { + "name": "@keeper-security/keeperapi", "version": "17.1.3", - "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-17.1.3.tgz", - "integrity": "sha512-xLRST98iT/aoF5gyYjMuOxXXtNQdOFWjrkfx+gxNo9SbSLFlNCeXLGsCSux23AsNXKvyAQnrmDmg04oAUFWPvA==", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", @@ -66,419 +27,46 @@ "faye-websocket": "^0.11.3", "form-data": "^4.0.4", "node-rsa": "^1.0.8" - } - }, - "node_modules/@noble/curves": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", - "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "2.0.1" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/post-quantum": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.5.4.tgz", - "integrity": "sha512-leww0zzIirrvwaYMPI9fj6aRIlA/c6Y0/lifQQ1YOOyHEr0MNH3yYpjXeiVG+tWdPps4XxGclFWX2INPO3Yo5w==", - "license": "MIT", - "dependencies": { - "@noble/curves": "~2.0.0", - "@noble/hashes": "~2.0.0" - }, - "engines": { - "node": ">= 20.19.0" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "devDependencies": { + "@babel/preset-env": "^7.23.5", + "@babel/preset-typescript": "^7.23.3", + "@rollup/plugin-commonjs": "^22.0.1", + "@rollup/plugin-node-resolve": "^13.3.0", + "@types/jest": "^24.0.15", + "@types/node": "^20.9.1", + "jest": "^29.6.1", + "jest-environment-jsdom": "^29.7.0", + "prettier": "^3.8.1", + "protobufjs": "^7.2.4", + "protobufjs-cli": "^1.1.1", + "rollup": "^2.79.2", + "rollup-plugin-typescript2": "^0.32.1", + "ts-jest": "^29.1.1", + "ts-node": "^8.10.2", + "typescript": "^4.0.1" } }, - "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "license": "MIT" + "node_modules/@keeper-security/keeperapi": { + "resolved": "../keeperapi", + "link": true }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.19.0" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, "license": "MIT", "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" + "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "license": "MIT" - }, "node_modules/asmcrypto.js": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/asmcrypto.js/-/asmcrypto.js-2.3.2.tgz", "integrity": "sha512-3FgFARf7RupsZETQ1nHnhLUUvpcttcCq1iZCaVAbJZbCZ5VNRrNyvpDyHTOb0KC3llFcsyOT/a99NZcCbeiEsA==", "license": "MIT" }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "license": "MIT" - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "license": "Apache-2.0", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", - "license": "MIT" - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "license": "ISC" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-rsa": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.1.1.tgz", - "integrity": "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==", - "license": "MIT", - "dependencies": { - "asn1": "^0.2.4" - } - }, "node_modules/prettier": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", @@ -495,75 +83,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, "node_modules/typescript": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", @@ -578,48 +97,11 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "license": "MIT" - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, "license": "MIT" - }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "license": "Apache-2.0", - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "license": "MIT", - "engines": { - "node": ">=6" - } } } } diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json index 008b1422..7332256d 100644 --- a/KeeperSdk/package.json +++ b/KeeperSdk/package.json @@ -8,10 +8,13 @@ "directory": "KeeperSdk" }, "license": "ISC", - "main": "src/index.ts", + "main": "dist/index.js", + "browser": "dist/browser.js", + "types": "dist/index.d.ts", "scripts": { "build": "tsc", - "clean": "rm -rf dist", + "clean": "rm -rf dist node_modules", + "rebuild": "npm run clean && npm install && npm run build", "link-local": "npm link ../keeperapi", "format": "prettier --write .", "format:check": "prettier --check .", @@ -23,6 +26,7 @@ "dependencies": { "@keeper-security/keeperapi": "17.1.4", "ts-node": "^10.7.0", + "asmcrypto.js": "^2.3.2", "typescript": "^4.6.3" }, "devDependencies": { diff --git a/KeeperSdk/src/api.ts b/KeeperSdk/src/api.ts new file mode 100644 index 00000000..def13c4e --- /dev/null +++ b/KeeperSdk/src/api.ts @@ -0,0 +1,241 @@ +export { SessionManager } from './auth/SessionManager' +export type { + KeeperJsonConfig, + ConfigLoader, + ConfigurationUser, + ConfigurationServerConfig, + ConfigurationDeviceConfig, +} from './auth/SessionManager' +export { connectSdkPlatform, getSdkPlatform, isSdkPlatformConnected } from './platform' +export type { SdkPlatform, SdkReadline, SdkRuntime } from './platform' + +export { InMemoryStorage } from './storage/InMemoryStorage' + +export { + Logger, + ConsoleLogger, + LogLevel, + logger, + setLogger, + getLogger, + resetLogger, + KeeperSdkError, + isKeeperError, + extractErrorMessage, + extractResultCode, + SdkDefaults, + AuthDefaults, + ResultCodes, + KEEPER_PUBLIC_HOSTS, + isBoolean, + isString, + isNonEmptyString, + isNumber, + isObject, + anyIsBoolean, + EMAIL_PATTERN, + EMAIL_LIST_SEPARATOR_PATTERN, + isValidEmail, +} from './utils' +export type { ILogger, Nullable, Optional, DeepPartial, Immutable } from './utils' + +export { + searchRecords, + formatRecord, + getRecordTitle, + getRecordType, + getRecordFields, + getRecordSummary, + getRecordPassword, + getRecordLogin, + getRecordUrl, + getRecordTotpUrl, + RecordVersion, +} from './records/RecordUtils' +export type { RecordSummary } from './records/RecordUtils' +export { parseTotpUrl, getTotpCode } from './records/Totp' +export type { TotpAlgorithm, TotpParams, TotpCode } from './records/Totp' +export { addRecord, updateRecord, deleteRecord, getRecordHistory, moveRecord } from './records/RecordOperations' +export type { + PasswordRecordData, + TypedRecordData, + RecordFieldInput, + NewRecordInput, + AddRecordResult, + UpdateRecordResult, + DeleteRecordResult, + HistoryEntry, + RecordHistoryResult, + MoveRecordInput, + MoveRecordResult, +} from './records/RecordOperations' + +export { shareRecord, removeRecordShare, getRecordShareInfo } from './sharing/Sharing' +export type { + ShareRecordInput, + ShareRecordResult, + RemoveShareInput, + RemoveShareResult, + RecordShareInfo, + RecordUserPermission, + RecordSharedFolderPermission, +} from './sharing/Sharing' + +export { KeeperVault } from './vault/KeeperVault' +export type { KeeperVaultConfig, VaultSummary } from './vault/KeeperVault' + +export type { SessionRestoreInput } from './auth/sessionRestore' +export { + toSessionParams, + validateSessionRestoreInput, + sessionRestoreFromJson, + resolveSessionRestorePayload, +} from './auth/sessionRestore' + +export { getFolder, findFolder, GetFolderFormat } from './folders/getFolder' +export type { + GetFolderOptions, + GetFolderResult, + GetFolderResultFolder, + GetFolderResultSharedFolder, + GetFolderFormatInput, + FoundFolder, +} from './folders/getFolder' + +export { + FolderKind, + ParentFolderKind, + FolderObjectType, + FolderResultStatus, + DeleteResolution, + DeleteObjectType, + folderKindFromString, +} from './folders/folderHelpers' +export type { FolderKindOrLiteral } from './folders/folderHelpers' + +export { listFolder, findFolderUidByNameOrUid, listRootUserFolders } from './folders/listFolder' +export type { + ListFolderOptions, + ListFolderResult, + ListFolderFolderSimple, + ListFolderRecordSimple, + ListFolderFolderDetail, + ListFolderRecordDetail, +} from './folders/listFolder' + +export { + listSharedFolders, + formatSharedFoldersTable, + renderSharedFoldersAsciiTable, +} from './sharedFolders/listSharedFolders' +export type { + ListSharedFoldersOptions, + ListSharedFolderRow, + FormattedSharedFoldersTable, +} from './sharedFolders/listSharedFolders' + +export { shareFolder, ShareFolderAction, ShareFolderUserResultStatus } from './sharedFolders/shareFolder' +export type { + ShareFolderActionInput, + ShareFolderInput, + ShareFolderResult, + ShareFolderUserStatus, +} from './sharedFolders/shareFolder' + +export { + changeDirectory, + createVaultFolderSession, + tryResolvePath, + resolveSingleFolder, + getWorkingFolderDisplayName, + findParentFolderUid, + splitPathComponents, +} from './folders/changeDirectory' +export type { VaultFolderSession, ChangeDirectoryResult, TryResolvePathResult } from './folders/changeDirectory' + +export { addFolder, mkdir } from './folders/addFolder' +export type { AddFolderInput, AddFolderResult, MkdirOptions } from './folders/addFolder' + +export { updateFolder, renameFolder, updateSharedFolderPermissions } from './folders/updateFolder' +export type { UpdateFolderInput, UpdateFolderResult, RenameFolderResult } from './folders/updateFolder' + +export { + deleteFolder, + rmdir, + resolveRmdirPatternsToFolderUids, + buildFolderDeleteObject, +} from './folders/deleteFolder' +export type { DeleteFolderResult, RmdirOptions } from './folders/deleteFolder' + +export { + buildFolderTree, + renderFolderTreeAscii, + folderTreeAscii, + userPermissionToText, + recordPermissionToText, +} from './folders/folderTree' +export type { FolderTreeBuildOptions, FolderTreeNode, FolderTreeResult } from './folders/folderTree' + +export { FolderManager } from './folders/FolderManager' +export type { AuthProvider, SharedFolderPermissionsInput } from './folders/FolderManager' + +export { SharedFolderManager } from './sharedFolders/SharedFolderManager' + +export { + dispatchCliLine, + dispatchKeeperCli, + ensureKeeperCliRegistry, + registerCliCommand, + registerCliAlias, + getCliCommand, + listCliCommands, + listCliCommandNames, + listCliCommandNamesForLoginState, + listCliCommandsForLoginState, + isAuthCliCommand, + listDocumentedCommands, + getDetailedHelpPage, + formatDetailedHelpForCommand, + tokenizeArguments, + parseCliArgs, + wantsCliHelp, + rejectUnknownOptions, + loginWithCredentials, + loginWithSessionToken, + runLoginCommand, + runLogoutCommand, + KeeperCliParser, + createKeeperCliParser, + getKeeperCliPromptPrefix, + BUILTIN_CLI_COMMANDS, + registerBuiltinCliCommands, + listCommand, +} from './cli' +export type { KeeperCliParserOptions } from './cli' +export type { + CliResult, + ParsedCli, + CliCommandDefinition, + CliHelpDoc, + KeeperCliHost, + KeeperCliVault, +} from './cli' + +export { Auth, KeeperEnvironment, syncDown, Authentication } from '@keeper-security/keeperapi' + +export type { + DRecord, + DRecordMetadata, + DSharedFolder, + DTeam, + DUserFolder, + VaultStorage, + SyncResult, + SyncDownOptions, + ClientConfiguration, + DeviceConfig, + SessionStorage, + AuthUI3, + KeeperError, + LoginError, +} from '@keeper-security/keeperapi' diff --git a/KeeperSdk/src/auth/ConsoleAuthUI.ts b/KeeperSdk/src/auth/ConsoleAuthUI.ts index 69a53da1..e4f8cb89 100644 --- a/KeeperSdk/src/auth/ConsoleAuthUI.ts +++ b/KeeperSdk/src/auth/ConsoleAuthUI.ts @@ -1,7 +1,6 @@ -import readline from 'readline/promises' -import { setTimeout as delay } from 'timers/promises' import type { AuthUI3, DeviceApprovalChannel, TwoFactorChannelData } from '@keeper-security/keeperapi' import { Authentication } from '@keeper-security/keeperapi' +import { getSdkPlatform } from '../platform' import { logger, extractErrorMessage, KeeperSdkError, AuthDefaults, ResultCodes } from '../utils' export class ConsoleAuthUI implements AuthUI3 { @@ -54,18 +53,19 @@ export class ConsoleAuthUI implements AuthUI3 { } private static async waitWithCancel(timeoutMs: number, cancel?: Promise): Promise { + const platform = getSdkPlatform() if (!cancel) { - await delay(timeoutMs) + await platform.delay(timeoutMs) return } - await Promise.race([delay(timeoutMs), cancel]) + await Promise.race([platform.delay(timeoutMs), cancel]) } - public async waitForDeviceApproval(channels: DeviceApprovalChannel[], isCloud: boolean): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) + public async waitForDeviceApproval(channels: DeviceApprovalChannel[], _isCloud: boolean): Promise { + const rl = getSdkPlatform().createReadline( + typeof process !== 'undefined' ? process.stdin : undefined, + typeof process !== 'undefined' ? process.stdout : undefined + ) try { logger.info('\n--- Device Approval Required ---') @@ -106,10 +106,10 @@ export class ConsoleAuthUI implements AuthUI3 { } public async waitForTwoFactorCode(channels: TwoFactorChannelData[], cancel: Promise): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) + const rl = getSdkPlatform().createReadline( + typeof process !== 'undefined' ? process.stdin : undefined, + typeof process !== 'undefined' ? process.stdout : undefined + ) try { logger.info('\n--- Two-Factor Authentication Required ---') @@ -155,10 +155,10 @@ export class ConsoleAuthUI implements AuthUI3 { } public async getPassword(isAlternate: boolean): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) + const rl = getSdkPlatform().createReadline( + typeof process !== 'undefined' ? process.stdin : undefined, + typeof process !== 'undefined' ? process.stdout : undefined + ) try { const label = isAlternate ? 'alternate master password' : 'master password' return (await rl.question(`Enter your ${label}: `)).trim() diff --git a/KeeperSdk/src/auth/ConsoleLogin.ts b/KeeperSdk/src/auth/ConsoleLogin.ts index 3d0907e3..93f30bba 100644 --- a/KeeperSdk/src/auth/ConsoleLogin.ts +++ b/KeeperSdk/src/auth/ConsoleLogin.ts @@ -1,6 +1,7 @@ -import readline from 'readline/promises' import type { AuthUI3 } from '@keeper-security/keeperapi' import { KeeperVault } from '../vault/KeeperVault' +import { getSdkPlatform } from '../platform' +import type { SdkReadline } from '../platform' import { logger, extractResultCode, @@ -12,8 +13,7 @@ import { KEEPER_PUBLIC_HOSTS, } from '../utils' import { ConsoleAuthUI } from './ConsoleAuthUI' -import { FileConfigLoader } from './SessionManager' -import type { KeeperJsonConfig } from './SessionManager' +import type { KeeperJsonConfig } from './config' const DEFAULT_REGION = 'US' const MASK_CHAR = '*' @@ -35,8 +35,6 @@ type ConsoleHandlers = { stderrWrite: typeof process.stderr.write } -const defaultConfigLoader = new FileConfigLoader() - let rlManager: ReadlineManager | null = null let suppressionDepth = 0 let originals: ConsoleHandlers | null = null @@ -69,14 +67,11 @@ function classifyInputChar(ch: string): CliCharAction { } class ReadlineManager { - private rl: readline.Interface | null = null + private rl: SdkReadline | null = null - private getOrCreate(): readline.Interface { + private getOrCreate(): SdkReadline { if (!this.rl) { - this.rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) + this.rl = getSdkPlatform().createReadline(process.stdin, process.stdout) } return this.rl } @@ -154,7 +149,7 @@ export function prompt(question: string, masked = false): Promise { export async function loadKeeperConfig(preloaded?: KeeperJsonConfig): Promise { if (preloaded) return preloaded - return defaultConfigLoader.load() + return getSdkPlatform().createFileConfigLoader().load() } export async function resolveServer(username?: string, preloadedConfig?: KeeperJsonConfig): Promise { diff --git a/KeeperSdk/src/auth/SessionManager.ts b/KeeperSdk/src/auth/SessionManager.ts index ec3c7a9b..0370b3ce 100644 --- a/KeeperSdk/src/auth/SessionManager.ts +++ b/KeeperSdk/src/auth/SessionManager.ts @@ -1,6 +1,3 @@ -import fs from 'fs/promises' -import path from 'path' -import os from 'os' import { normal64Bytes, type DeviceConfig, @@ -8,37 +5,24 @@ import { type KeeperHost, type SessionParams, } from '@keeper-security/keeperapi' -import { logger, extractErrorMessage, SdkDefaults } from '../utils' +import { getSdkPlatform } from '../platform' +import { logger, extractErrorMessage } from '../utils' import type { Nullable } from '../utils' - -export type ConfigurationUser = { - user?: string - server?: string - last_device?: { device_token?: string } -} - -export type ConfigurationServerConfig = { - server?: string - clone_code?: string -} - -export type ConfigurationDeviceConfig = { - device_token?: string - private_key?: string - server_info?: Array -} - -export type KeeperJsonConfig = { - last_login?: string - last_server?: string - user?: string - server?: string - device_token?: string - private_key?: string - clone_code?: string - users?: Array - devices?: Array -} +import type { + ConfigLoader, + KeeperJsonConfig, + ConfigurationServerConfig, + ConfigurationUser, +} from './config' +import { isValidKeeperConfig } from './config' + +export type { + KeeperJsonConfig, + ConfigLoader, + ConfigurationUser, + ConfigurationServerConfig, + ConfigurationDeviceConfig, +} from './config' type ResolvedDevice = { deviceToken: Uint8Array @@ -51,41 +35,6 @@ type DeviceCacheEntry = { device: Nullable } -export interface ConfigLoader { - load(): Promise - save(config: KeeperJsonConfig): Promise - readonly configDir: string -} - -export class FileConfigLoader implements ConfigLoader { - public readonly configDir: string - - constructor(configDir?: string) { - this.configDir = configDir || path.join(os.homedir(), SdkDefaults.CONFIG_DIR) - } - - async load(): Promise { - const configPath = path.join(this.configDir, 'config.json') - try { - const content = await fs.readFile(configPath, 'utf-8') - const parsed: unknown = JSON.parse(content) - if (SessionManager.isValidKeeperConfig(parsed)) { - return parsed - } - } catch (err) { - logger.debug('Failed to load keeper config:', extractErrorMessage(err)) - } - return {} - } - - async save(config: KeeperJsonConfig): Promise { - const configPath = path.join(this.configDir, 'config.json') - await fs.writeFile(configPath, JSON.stringify(config, null, 2), { - mode: 0o600, - }) - } -} - export class SessionManager implements SessionStorage { private readonly configLoader: ConfigLoader private sessionParams: Nullable = null @@ -98,8 +47,10 @@ export class SessionManager implements SessionStorage { constructor(configDir?: string) constructor(loader: ConfigLoader) constructor(configDirOrLoader?: string | ConfigLoader) { - if (typeof configDirOrLoader === 'string' || configDirOrLoader === undefined) { - this.configLoader = new FileConfigLoader(configDirOrLoader as string | undefined) + if (typeof configDirOrLoader === 'string') { + this.configLoader = getSdkPlatform().createFileConfigLoader(configDirOrLoader) + } else if (configDirOrLoader === undefined) { + this.configLoader = getSdkPlatform().createFileConfigLoader() } else { this.configLoader = configDirOrLoader } @@ -187,7 +138,7 @@ export class SessionManager implements SessionStorage { ) if (user?.last_device?.device_token) { const device = (parsed.devices || []).find( - (configDevice) => configDevice.device_token === user.last_device.device_token + (configDevice) => configDevice.device_token === user.last_device!.device_token ) if (device?.server_info) { const serverInfo = device.server_info.find((entry) => entry.server === host) @@ -284,10 +235,6 @@ export class SessionManager implements SessionStorage { } public static isValidKeeperConfig(value: unknown): value is KeeperJsonConfig { - if (typeof value !== 'object' || value === null) return false - const obj = value as Record - if (obj.users !== undefined && !Array.isArray(obj.users)) return false - if (obj.devices !== undefined && !Array.isArray(obj.devices)) return false - return true + return isValidKeeperConfig(value) } } diff --git a/KeeperSdk/src/auth/UnavailableAuthUI.ts b/KeeperSdk/src/auth/UnavailableAuthUI.ts new file mode 100644 index 00000000..16278777 --- /dev/null +++ b/KeeperSdk/src/auth/UnavailableAuthUI.ts @@ -0,0 +1,23 @@ +import type { AuthUI3, DeviceApprovalChannel, TwoFactorChannelData } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes } from '../utils' + +const MSG = + 'Console authentication is disabled (useConsoleAuth: false). Provide authUI or use a host that handles login (e.g. keeper-shell password prompt).' + +/** Placeholder when Node readline-based ConsoleAuthUI must not be used. */ +export class UnavailableAuthUI implements AuthUI3 { + public async waitForDeviceApproval(_channels: DeviceApprovalChannel[], _isCloud: boolean): Promise { + throw new KeeperSdkError(MSG, ResultCodes.NOT_LOGGED_IN) + } + + public async waitForTwoFactorCode( + _channels: TwoFactorChannelData[], + _cancel: Promise + ): Promise { + throw new KeeperSdkError(MSG, ResultCodes.NOT_LOGGED_IN) + } + + public async getPassword(_isAlternate: boolean): Promise { + throw new KeeperSdkError(MSG, ResultCodes.NOT_LOGGED_IN) + } +} diff --git a/KeeperSdk/src/auth/config.ts b/KeeperSdk/src/auth/config.ts new file mode 100644 index 00000000..af3d2dd7 --- /dev/null +++ b/KeeperSdk/src/auth/config.ts @@ -0,0 +1,42 @@ +export type ConfigurationUser = { + user?: string + server?: string + last_device?: { device_token?: string } +} + +export type ConfigurationServerConfig = { + server?: string + clone_code?: string +} + +export type ConfigurationDeviceConfig = { + device_token?: string + private_key?: string + server_info?: Array +} + +export type KeeperJsonConfig = { + last_login?: string + last_server?: string + user?: string + server?: string + device_token?: string + private_key?: string + clone_code?: string + users?: Array + devices?: Array +} + +export interface ConfigLoader { + load(): Promise + save(config: KeeperJsonConfig): Promise + readonly configDir: string +} + +export function isValidKeeperConfig(value: unknown): value is KeeperJsonConfig { + if (typeof value !== 'object' || value === null) return false + const obj = value as Record + if (obj.users !== undefined && !Array.isArray(obj.users)) return false + if (obj.devices !== undefined && !Array.isArray(obj.devices)) return false + return true +} diff --git a/KeeperSdk/src/auth/node/FileConfigLoader.ts b/KeeperSdk/src/auth/node/FileConfigLoader.ts new file mode 100644 index 00000000..61c4d08e --- /dev/null +++ b/KeeperSdk/src/auth/node/FileConfigLoader.ts @@ -0,0 +1,36 @@ +import fs from 'fs/promises' +import path from 'path' +import os from 'os' +import type { ConfigLoader, KeeperJsonConfig } from '../config' +import { isValidKeeperConfig } from '../config' +import { logger, extractErrorMessage, SdkDefaults } from '../../utils' + +/** Node-only: read/write `~/.keeper/config.json`. */ +export class FileConfigLoader implements ConfigLoader { + public readonly configDir: string + + constructor(configDir?: string) { + this.configDir = configDir || path.join(os.homedir(), SdkDefaults.CONFIG_DIR) + } + + async load(): Promise { + const configPath = path.join(this.configDir, 'config.json') + try { + const content = await fs.readFile(configPath, 'utf-8') + const parsed: unknown = JSON.parse(content) + if (isValidKeeperConfig(parsed)) { + return parsed + } + } catch (err) { + logger.debug('Failed to load keeper config:', extractErrorMessage(err)) + } + return {} + } + + async save(config: KeeperJsonConfig): Promise { + const configPath = path.join(this.configDir, 'config.json') + await fs.writeFile(configPath, JSON.stringify(config, null, 2), { + mode: 0o600, + }) + } +} diff --git a/KeeperSdk/src/auth/sessionRestore.ts b/KeeperSdk/src/auth/sessionRestore.ts new file mode 100644 index 00000000..0e64a4e0 --- /dev/null +++ b/KeeperSdk/src/auth/sessionRestore.ts @@ -0,0 +1,201 @@ +import { Authentication, normal64Bytes, type SessionParams } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes } from '../utils' + +type UserType = SessionParams['userType'] + +const UserTypeValues = { + normal: 'normal' as UserType, + cloudSso: 'cloud_sso' as UserType, + onsiteSso: 'onsite_sso' as UserType, +} + +/** String form of {@link SessionParams} as exported from extension / vault storage. */ +export type SessionRestoreInput = { + accountUid: string + clientKey: string + dataKey: string + eccPrivateKey: string + eccPublicKey: string + messageSessionUid: string + privateKey: string + sessionToken: string + sessionTokenType: number | string + username: string + userType: number | string + ssoLogoutUrl?: string + ssoSessionId?: string + enterprisePublicKey?: string + enterpriseEccPublicKey?: string +} + +const REQUIRED_KEYS: (keyof SessionRestoreInput)[] = [ + 'accountUid', + 'clientKey', + 'dataKey', + 'eccPrivateKey', + 'eccPublicKey', + 'messageSessionUid', + 'privateKey', + 'sessionToken', + 'sessionTokenType', + 'username', + 'userType', +] + +function decodeBytes(label: string, value: string | undefined, required: boolean): Uint8Array | undefined { + const trimmed = typeof value === 'string' ? value.trim() : '' + if (!trimmed) { + if (required) { + throw new KeeperSdkError(`restore-session: missing required field: ${label}`, ResultCodes.MISSING_USERNAME) + } + return undefined + } + try { + return normal64Bytes(trimmed) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + throw new KeeperSdkError(`restore-session: invalid base64 for ${label}: ${msg}`, ResultCodes.INVALID_CREDENTIALS) + } +} + +function parseUserType(value: number | string): UserType { + if (typeof value === 'number') { + switch (value) { + case 0: + return UserTypeValues.normal + case 1: + return UserTypeValues.cloudSso + case 2: + return UserTypeValues.onsiteSso + default: + break + } + } + const s = String(value).toLowerCase() + if (s === 'normal' || s === '0') return UserTypeValues.normal + if (s === 'cloud_sso' || s === 'cloudsso' || s === '1') return UserTypeValues.cloudSso + if (s === 'onsite_sso' || s === 'onsitesso' || s === '2') return UserTypeValues.onsiteSso + throw new KeeperSdkError(`restore-session: unknown userType: ${value}`, ResultCodes.INVALID_CREDENTIALS) +} + +function parseSessionTokenType(value: number | string): Authentication.SessionTokenType { + const n = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(n)) { + throw new KeeperSdkError(`restore-session: invalid sessionTokenType: ${value}`, ResultCodes.INVALID_CREDENTIALS) + } + return n as Authentication.SessionTokenType +} + +export function validateSessionRestoreInput(input: Partial): SessionRestoreInput { + const missing = REQUIRED_KEYS.filter((k) => { + const v = input[k] + return v === undefined || v === null || (typeof v === 'string' && !v.trim()) + }) + if (missing.length > 0) { + throw new KeeperSdkError( + `restore-session: missing required field(s): ${missing.join(', ')}`, + ResultCodes.MISSING_USERNAME + ) + } + return input as SessionRestoreInput +} + +/** Build keeperapi {@link SessionParams} from extension-style strings. */ +export function toSessionParams(input: SessionRestoreInput): SessionParams { + const enterprisePublicKey = decodeBytes('enterprisePublicKey', input.enterprisePublicKey, false) + const enterpriseEccPublicKey = decodeBytes('enterpriseEccPublicKey', input.enterpriseEccPublicKey, false) + + return { + accountUid: decodeBytes('accountUid', input.accountUid, true)!, + clientKey: decodeBytes('clientKey', input.clientKey, true)!, + dataKey: decodeBytes('dataKey', input.dataKey, true)!, + eccPrivateKey: decodeBytes('eccPrivateKey', input.eccPrivateKey, true)!, + eccPublicKey: decodeBytes('eccPublicKey', input.eccPublicKey, true)!, + messageSessionUid: decodeBytes('messageSessionUid', input.messageSessionUid, true)!, + privateKey: decodeBytes('privateKey', input.privateKey, true)!, + sessionToken: input.sessionToken.trim(), + sessionTokenType: parseSessionTokenType(input.sessionTokenType), + username: input.username.trim(), + userType: parseUserType(input.userType), + ssoLogoutUrl: input.ssoLogoutUrl?.trim() ?? '', + ssoSessionId: input.ssoSessionId?.trim() ?? '', + ...(enterprisePublicKey ? { enterprisePublicKey } : {}), + ...(enterpriseEccPublicKey ? { enterpriseEccPublicKey } : {}), + } +} + +function assertBodyIsJsonNotHtml(body: string, source: string): void { + const head = body.trimStart().slice(0, 32).toLowerCase() + if (head.startsWith(') +} + +/** Parse `--from-json` payload, or read a file when the value is not JSON. */ +export async function resolveSessionRestorePayload( + raw: string, + readFile?: (path: string) => Promise +): Promise { + const text = raw.trim() + if (readFile && looksLikeFilePath(text)) { + const body = await readFile(text) + assertBodyIsJsonNotHtml(body, text) + return sessionRestoreFromJson(body) + } + try { + return sessionRestoreFromJson(text) + } catch (e) { + if (looksLikeInlineJson(text) || !readFile) { + throw e + } + const body = await readFile(text) + assertBodyIsJsonNotHtml(body, text) + return sessionRestoreFromJson(body) + } +} diff --git a/KeeperSdk/src/browser.ts b/KeeperSdk/src/browser.ts new file mode 100644 index 00000000..463daba6 --- /dev/null +++ b/KeeperSdk/src/browser.ts @@ -0,0 +1,6 @@ +import { connectSdkPlatform } from './platform' +import { browserSdkPlatform } from './platform/browser/platform' + +connectSdkPlatform(browserSdkPlatform) + +export * from './api' diff --git a/KeeperSdk/src/cli/access.ts b/KeeperSdk/src/cli/access.ts new file mode 100644 index 00000000..1f6f1ebf --- /dev/null +++ b/KeeperSdk/src/cli/access.ts @@ -0,0 +1,31 @@ +import type { CliCommandDefinition } from './types' +import { listCliCommands, resolveCliCommandName } from './registry' + +/** Commands available before a vault session exists. */ +export const AUTH_CLI_COMMAND_NAMES = new Set([ + 'help', + 'login', + 'restore-session', + 'register-device', +]) + +export function isAuthCliCommand(name: string): boolean { + const resolved = resolveCliCommandName(name) + return resolved != null && AUTH_CLI_COMMAND_NAMES.has(resolved) +} + +export function filterCliCommandsForLoginState( + commands: readonly CliCommandDefinition[], + loggedIn: boolean +): CliCommandDefinition[] { + if (loggedIn) return [...commands] + return commands.filter((c) => AUTH_CLI_COMMAND_NAMES.has(c.name)) +} + +export function listCliCommandsForLoginState(loggedIn: boolean): CliCommandDefinition[] { + return filterCliCommandsForLoginState(listCliCommands(), loggedIn) +} + +export function listCliCommandNamesForLoginState(loggedIn: boolean): readonly string[] { + return listCliCommandsForLoginState(loggedIn).map((c) => c.name) +} diff --git a/KeeperSdk/src/cli/builtinCommands.ts b/KeeperSdk/src/cli/builtinCommands.ts new file mode 100644 index 00000000..aed2457d --- /dev/null +++ b/KeeperSdk/src/cli/builtinCommands.ts @@ -0,0 +1,47 @@ +import type { CliCommandDefinition } from './types' +import { registerCliCommand } from './registry' +import { helpCommand } from './commands/help' +import { loginCommand } from './commands/login' +import { logoutCommand } from './commands/logout' +import { registerDeviceCommand } from './commands/registerDevice' +import { restoreSessionCommand } from './commands/restoreSession' +import { syncCommand } from './commands/sync' +import { usersCommand } from './commands/users' +import { vaultCommand } from './commands/vault' +import { getCommand } from './commander/get' +import { cdCommand, lsCommand, mkdirCommand, treeCommand } from './commander/nav' +import { + listCommand, + listSfCommand, + listTeamCommand, + searchCommand, + whoamiCommand, +} from './commander/misc' + +/** Built-in CLI commands (Keeper Commander-style vault shell). */ +export const BUILTIN_CLI_COMMANDS: readonly CliCommandDefinition[] = [ + helpCommand, + loginCommand, + registerDeviceCommand, + restoreSessionCommand, + syncCommand, + vaultCommand, + getCommand, + listCommand, + lsCommand, + cdCommand, + treeCommand, + mkdirCommand, + searchCommand, + listSfCommand, + listTeamCommand, + whoamiCommand, + usersCommand, + logoutCommand, +] + +export function registerBuiltinCliCommands(): void { + for (const def of BUILTIN_CLI_COMMANDS) { + registerCliCommand(def) + } +} diff --git a/KeeperSdk/src/cli/commandHelpers.ts b/KeeperSdk/src/cli/commandHelpers.ts new file mode 100644 index 00000000..e4fa6652 --- /dev/null +++ b/KeeperSdk/src/cli/commandHelpers.ts @@ -0,0 +1,24 @@ +import type { CliResult, KeeperCliHost, KeeperCliVault } from './types' +import { ensureLoggedIn } from './commands/login' + +export async function ensureSession(host: KeeperCliHost): Promise { + const v = host.getVault() + if (v.isLoggedIn) return null + const r = await ensureLoggedIn(host) + return r.code === 0 ? null : r +} + +export function ensureCapability( + v: KeeperCliVault, + name: K, + context: string +): CliResult | null { + if (typeof v[name] !== 'function') { + return { + code: 1, + out: '', + err: `${context}: this host does not expose KeeperCliVault.${String(name)}.\n`, + } + } + return null +} diff --git a/KeeperSdk/src/cli/commander/get.ts b/KeeperSdk/src/cli/commander/get.ts new file mode 100644 index 00000000..e31a19bf --- /dev/null +++ b/KeeperSdk/src/cli/commander/get.ts @@ -0,0 +1,41 @@ +import type { CliCommandDefinition } from '../types' +import { wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { executeGet } from './getCore' + +export const getCommand: CliCommandDefinition = { + name: 'get', + order: 10, + aliases: ['g'], + description: 'Get details of a record, folder, or team by UID or title.', + usage: 'get [--format {detail,json,password,fields}] [--unmask]', + flagOptions: ['--format', '--unmask', '--detail', '--json'], + help: { + title: 'get — record/folder/team details (Keeper Commander)', + synopsis: 'usage: get [--unmask] [--format {detail,json,password,fields}] uid', + description: + ' Resolves a vault object by UID or title. Records support all output formats; folders and teams support detail/json.', + arguments: ' uid Record, folder, or team UID or title.', + options: ` --format {detail,json,password,fields} + detail (default): human-readable output. + json: JSON object. + password: password field only (records). + fields: JSON array of {name, value} (records). + --unmask Show sensitive field values (records). + --help, -h Show this help.`, + examples: ` get "Amazon" + get AbCdEf123456 --format json --unmask + get MyFolderUid --format json`, + seeAlso: ' ls, search, record-update', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(getCommand), err: '' } + } + try { + return await executeGet(host, parsed, 'get') + } catch (e) { + return { code: 1, out: '', err: host.formatError('get', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commander/getCore.ts b/KeeperSdk/src/cli/commander/getCore.ts new file mode 100644 index 00000000..ceec4a65 --- /dev/null +++ b/KeeperSdk/src/cli/commander/getCore.ts @@ -0,0 +1,153 @@ +import type { DRecord } from '@keeper-security/keeperapi' +import { + formatRecord, + formatRecordFields, + getRecordPassword, + getRecordTitle, +} from '../../records/RecordUtils' +import { formatTeamView, teamViewTable } from '../../teams/viewTeam' +import type { CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt } from '../parse' +import { ensureCapability, ensureSession } from '../commandHelpers' +import { GetFolderFormat } from '../../folders/getFolder' + +export type GetOutputFormat = 'detail' | 'json' | 'password' | 'fields' + +export function resolveGetFormat(parsed: ParsedCli): GetOutputFormat { + const raw = getOpt(parsed.opts, 'format')?.toLowerCase() + if (raw === 'json' || hasOpt(parsed.opts, 'json')) return 'json' + if (raw === 'password') return 'password' + if (raw === 'fields') return 'fields' + if (raw === 'detail') return 'detail' + return hasOpt(parsed.opts, 'detail') ? 'detail' : 'detail' +} + +export function resolveGetUnmask(parsed: ParsedCli): boolean { + return hasOpt(parsed.opts, 'unmask') +} + +export function getGetTarget(parsed: ParsedCli): string | undefined { + return parsed.positional[0]?.trim() || undefined +} + +async function outputRecord( + host: KeeperCliHost, + record: DRecord, + fmt: GetOutputFormat, + unmask: boolean, + cmd: string +): Promise { + if (fmt === 'password') { + const pw = getRecordPassword(record) + return { code: 0, out: pw ? `${pw}\n` : '', err: pw ? '' : `${cmd}: record has no password field\n` } + } + if (fmt === 'fields') { + return { code: 0, out: JSON.stringify(formatRecordFields(record, unmask), null, 2) + '\n', err: '' } + } + if (fmt === 'json') { + return { code: 0, out: JSON.stringify(record, null, 2) + '\n', err: '' } + } + return { code: 0, out: formatRecord(record, { showDetails: true, unmask }) + '\n', err: '' } +} + +async function tryGetFolder( + host: KeeperCliHost, + target: string, + fmt: GetOutputFormat, + cmd: string +): Promise { + const v = host.getVault() + if (!v.getFolder) return null + try { + const res = await v.getFolder(target, { + format: fmt === 'json' ? GetFolderFormat.JSON : GetFolderFormat.Detail, + }) + if (fmt === 'json') { + const json = (res as { json?: Record }).json ?? res + return { code: 0, out: JSON.stringify(json, null, 2) + '\n', err: '' } + } + const name = 'name' in res ? res.name : target + const uid = + 'folder_uid' in res + ? res.folder_uid + : 'shared_folder_uid' in res + ? res.shared_folder_uid + : target + return { code: 0, out: `${name}\t${uid}\n`, err: '' } + } catch { + return null + } +} + +async function tryGetTeam( + host: KeeperCliHost, + target: string, + fmt: GetOutputFormat, + cmd: string +): Promise { + const v = host.getVault() + if (!v.viewTeam) return null + try { + const view = await v.viewTeam(target) + if (fmt === 'json') { + return { code: 0, out: JSON.stringify(view, null, 2) + '\n', err: '' } + } + if (fmt === 'password' || fmt === 'fields') { + return { code: 1, out: '', err: `${cmd}: --format ${fmt} applies to records only\n` } + } + const formatted = formatTeamView(view, { verbose: true }) + return { code: 0, out: teamViewTable(formatted) + '\n', err: '' } + } catch { + return null + } +} + +async function tryGetSharedFolderByUid( + host: KeeperCliHost, + target: string, + fmt: GetOutputFormat, + cmd: string +): Promise { + const v = host.getVault() + const hit = v.getSharedFolders().find((sf) => sf.uid === target) + if (!hit) return null + if (fmt === 'json') { + return { code: 0, out: JSON.stringify(hit, null, 2) + '\n', err: '' } + } + if (fmt === 'password' || fmt === 'fields') { + return { code: 1, out: '', err: `${cmd}: --format ${fmt} applies to records only\n` } + } + return { code: 0, out: `${hit.name ?? '(unnamed)'}\t${hit.uid}\n`, err: '' } +} + +/** Commander-style `get` (record, folder, team, or shared folder by UID/title). */ +export async function executeGet(host: KeeperCliHost, parsed: ParsedCli, cmd = 'get'): Promise { + const target = getGetTarget(parsed) + if (!target) { + return { code: 1, out: '', err: `${cmd}: UID parameter is required\n` } + } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const fmt = resolveGetFormat(parsed) + const unmask = resolveGetUnmask(parsed) + + await v.sync() + + const sf = await tryGetSharedFolderByUid(host, target, fmt, cmd) + if (sf) return sf + + const folder = await tryGetFolder(host, target, fmt, cmd) + if (folder) return folder + + const team = await tryGetTeam(host, target, fmt, cmd) + if (team) return team + + const cap = ensureCapability(v, 'findRecord', cmd) + if (cap) return cap + const record = v.findRecord!(target) + if (!record) { + return { code: 1, out: '', err: `${cmd}: cannot find any object matching "${target}"\n` } + } + return outputRecord(host, record, fmt, unmask, cmd) +} diff --git a/KeeperSdk/src/cli/commander/index.ts b/KeeperSdk/src/cli/commander/index.ts new file mode 100644 index 00000000..fb25cce5 --- /dev/null +++ b/KeeperSdk/src/cli/commander/index.ts @@ -0,0 +1,4 @@ +export { getCommand } from './get' +export { executeGet } from './getCore' +export { lsCommand, cdCommand, treeCommand, mkdirCommand } from './nav' +export { listCommand, searchCommand, listSfCommand, listTeamCommand, whoamiCommand } from './misc' diff --git a/KeeperSdk/src/cli/commander/misc.ts b/KeeperSdk/src/cli/commander/misc.ts new file mode 100644 index 00000000..ee7babae --- /dev/null +++ b/KeeperSdk/src/cli/commander/misc.ts @@ -0,0 +1,266 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureCapability, ensureSession } from '../commandHelpers' +import { formatTable } from '../table' +import { getRecordTitle } from '../../records/RecordUtils' +import { renderRecordsListTable } from '../../records/listRecordsTable' +import { recordUid } from '../utils' +import { formatSharedFoldersTable, renderSharedFoldersAsciiTable } from '../../sharedFolders/listSharedFolders' +import { formatTeamsTable, renderTeamsAsciiTable, type ListTeamSort } from '../../teams/listTeams' + +async function runList(host: KeeperCliHost, parsed: ParsedCli): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + await v.sync() + const records = v.getRecords() + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(records, null, 2) + '\n', err: '' } + } + if (records.length === 0) { + return { code: 0, out: '(no records)\n', err: '' } + } + const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') + const out = renderRecordsListTable(records, { verbose }) + '\n' + return { code: 0, out, err: '' } +} + +async function runSearch(host: KeeperCliHost, parsed: ParsedCli): Promise { + const pattern = parsed.positional.join(' ') || getOpt(parsed.opts, 'pattern') + if (!pattern?.trim()) { + return { code: 1, out: '', err: 'search: missing search terms. Usage: search \n' } + } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'findRecords', 'search') + if (cap) return cap + await v.sync() + const matches = v.findRecords!(pattern) + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(matches, null, 2) + '\n', err: '' } + } + if (matches.length === 0) { + return { code: 0, out: `(no records matched "${pattern}")\n`, err: '' } + } + const rows = matches.map((rec) => [recordUid(rec), getRecordTitle(rec)]) + return { code: 0, out: formatTable(['record_uid', 'title'], rows), err: '' } +} + +async function runListSf(host: KeeperCliHost, parsed: ParsedCli): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'listSharedFolders', 'list-sf') + if (cap) return cap + await v.sync() + const pattern = parsed.positional[0] ?? getOpt(parsed.opts, 'pattern') ?? null + const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') + const rows = v.listSharedFolders!({ pattern, verbose, includeDetails: verbose }) + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(rows, null, 2) + '\n', err: '' } + } + if (rows.length === 0) { + return { + code: 0, + out: pattern ? `(no shared folders matched "${pattern}")\n` : '(no shared folders)\n', + err: '', + } + } + const table = formatSharedFoldersTable(rows, { verbose }) + return { code: 0, out: renderSharedFoldersAsciiTable(table) + '\n', err: '' } +} + +const LIST_TEAM_SORTS = new Set(['company', 'team_uid', 'name']) + +async function runListTeam(host: KeeperCliHost, parsed: ParsedCli): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'listTeams', 'list-team') + if (cap) return cap + await v.sync() + const pattern = parsed.positional[0] ?? getOpt(parsed.opts, 'pattern') ?? null + const sortRaw = getOpt(parsed.opts, 'sort') + const sort = sortRaw && LIST_TEAM_SORTS.has(sortRaw as ListTeamSort) ? (sortRaw as ListTeamSort) : undefined + if (sortRaw && !sort) { + return { + code: 1, + out: '', + err: 'list-team: --sort must be company, team_uid, or name\n', + } + } + const verbose = hasOpt(parsed.opts, 'verbose') || hasOpt(parsed.opts, 'v') + const veryVerbose = hasOpt(parsed.opts, 'very-verbose') || hasOpt(parsed.opts, 'vv') + const rows = await v.listTeams!({ + pattern, + all: hasOpt(parsed.opts, 'all') || hasOpt(parsed.opts, 'a'), + verbose, + veryVerbose, + sort, + }) + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(rows, null, 2) + '\n', err: '' } + } + if (rows.length === 0) { + return { code: 0, out: pattern ? `(no teams matched "${pattern}")\n` : '(no teams)\n', err: '' } + } + const table = formatTeamsTable(rows, { + style: 'commander', + showMember: verbose || veryVerbose, + }) + return { code: 0, out: renderTeamsAsciiTable(table) + '\n', err: '' } +} + +async function runWhoami(host: KeeperCliHost): Promise { + const v = host.getVault() + if (!v.isLoggedIn) { + return { code: 1, out: '', err: 'whoami: not logged in\n' } + } + const username = + (await host.getAccountUsername?.()) ?? host.envString('KEEPER_USER') ?? host.envString('KEEPER_USERNAME') + const summary = v.getSummary?.() + const lines = [`username: ${username ?? '(unknown)'}`] + if (summary) { + lines.push( + `records: ${summary.recordCount}`, + `folders: ${summary.folderCount}`, + `shared_folders: ${summary.sharedFolderCount}`, + `teams: ${summary.teamCount}` + ) + } + return { code: 0, out: lines.join('\n') + '\n', err: '' } +} + +export const listCommand: CliCommandDefinition = { + name: 'list', + order: 14, + aliases: ['l'], + description: 'List all vault records (Commander table).', + usage: 'list [--verbose|-v] [--json]', + flagOptions: ['--json', '--verbose', '-v'], + help: { + title: 'list — all records (Keeper Commander)', + synopsis: 'usage: list [--verbose]', + description: + ' Syncs and prints every record in a Commander-style table: uid, type, title, description, shared, and record category.', + options: ' --verbose, -v Do not truncate long columns (default max width 40).', + seeAlso: ' get, search, ls', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listCommand), err: '' } + try { + return await runList(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('list', e) } + } + }, +} + +export const searchCommand: CliCommandDefinition = { + name: 'search', + order: 15, + aliases: ['s'], + description: 'Search vault records by text.', + usage: 'search [--json]', + flagOptions: ['--json', '--pattern'], + help: { + title: 'search — find records (Keeper Commander)', + synopsis: 'usage: search ', + description: ' Space-separated terms; all terms must match somewhere in the record.', + examples: ' search amazon\n search bank account', + seeAlso: ' get, ls', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(searchCommand), err: '' } + try { + return await runSearch(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('search', e) } + } + }, +} + +export const listSfCommand: CliCommandDefinition = { + name: 'list-sf', + order: 16, + aliases: ['lsf'], + description: 'List shared folders.', + usage: 'list-sf [pattern] [--verbose] [--json]', + flagOptions: ['--verbose', '-v', '--json', '--pattern'], + help: { + title: 'list-sf — shared folders (Keeper Commander)', + synopsis: 'usage: list-sf [pattern]', + seeAlso: ' ls, get', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listSfCommand), err: '' } + try { + return await runListSf(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('list-sf', e) } + } + }, +} + +export const listTeamCommand: CliCommandDefinition = { + name: 'list-team', + order: 17, + aliases: ['lt'], + description: 'List teams (Commander table).', + usage: 'list-team [pattern] [--all|-a] [--verbose|-v] [--very-verbose|-vv] [--sort company|team_uid|name] [--json]', + flagOptions: [ + '--json', + '--pattern', + '--all', + '-a', + '--verbose', + '-v', + '--very-verbose', + '-vv', + '--sort', + ], + help: { + title: 'list-team — teams (Keeper Commander)', + synopsis: 'usage: list-team [pattern] [--all] [--verbose]', + description: + ' Lists teams from share contacts. Default: primary organization only. --all includes teams from other enterprises.', + options: ` --all, -a All teams in contacts (not just primary org) + --verbose, -v Include Member column (enterprise cache) + --very-verbose, -vv Fetch members via API when not cached + --sort company (default), team_uid, or name`, + seeAlso: ' get, whoami', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(listTeamCommand), err: '' } + try { + return await runListTeam(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('list-team', e) } + } + }, +} + +export const whoamiCommand: CliCommandDefinition = { + name: 'whoami', + order: 18, + description: 'Display current user and vault counts.', + usage: 'whoami', + help: { + title: 'whoami — current user (Keeper Commander)', + synopsis: 'usage: whoami', + seeAlso: ' login, sync-down', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(whoamiCommand), err: '' } + if (parsed.opts.size > 0 || parsed.positional.length > 0) { + return { code: 1, out: '', err: 'whoami: unexpected arguments\n' } + } + try { + return await runWhoami(host) + } catch (e) { + return { code: 1, out: '', err: host.formatError('whoami', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commander/nav.ts b/KeeperSdk/src/cli/commander/nav.ts new file mode 100644 index 00000000..0ff0e268 --- /dev/null +++ b/KeeperSdk/src/cli/commander/nav.ts @@ -0,0 +1,234 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureCapability, ensureSession } from '../commandHelpers' +import { formatTable } from '../table' + +function lsPath(parsed: ParsedCli): string | undefined { + return parsed.positional[0] +} + +function formatLs( + result: { + detail: boolean + folders: Array<{ uid: string; name: string }> + records: Array<{ uid: string; name: string; type?: string }> + }, + detail: boolean +): string { + if (result.folders.length + result.records.length === 0) return '(empty)\n' + + const headers = detail ? ['flags', 'uid', 'name', 'type'] : ['kind', 'uid', 'name'] + const rows: string[][] = [] + for (const f of result.folders) { + const flags = ((f as { flags?: string }).flags ?? '').trim() + rows.push(detail ? [flags || 'f---', f.uid, f.name, ''] : ['dir', f.uid, f.name]) + } + for (const r of result.records) { + const flags = ((r as { flags?: string }).flags ?? '').trim() + const type = r.type ?? '' + rows.push(detail ? [flags || 'r---', r.uid, r.name, type] : ['rec', r.uid, r.name]) + } + return formatTable(headers, rows) +} + +async function runLs(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'listFolder', cmd) + if (cap) return cap + await v.sync() + + const detail = hasOpt(parsed.opts, 'detail') || hasOpt(parsed.opts, 'list') || hasOpt(parsed.opts, 'l') + const foldersOnly = hasOpt(parsed.opts, 'folders') || hasOpt(parsed.opts, 'f') + const recordsOnly = hasOpt(parsed.opts, 'records') || hasOpt(parsed.opts, 'r') + const target = lsPath(parsed) + + const listOpts = { + detail, + showFolders: recordsOnly ? false : true, + showRecords: foldersOnly ? false : true, + } + + if (!target) { + const result = await v.listFolder!({ ...listOpts }) + return { code: 0, out: formatLs(result, detail), err: '' } + } + + if (!v.changeDirectory || !v.getCurrentFolderUid) { + return { code: 1, out: '', err: `${cmd}: host lacks navigation capabilities.\n` } + } + + const originalUid = v.getCurrentFolderUid() + let resolvedUid: string | null + try { + const cd = await v.changeDirectory(target) + resolvedUid = cd.folderUid + } catch (e) { + return { code: 1, out: '', err: host.formatError(`${cmd} ${target}`, e) } + } + try { + const result = await v.listFolder!({ folderUid: resolvedUid ?? null, ...listOpts }) + return { code: 0, out: formatLs(result, detail), err: '' } + } finally { + if (resolvedUid !== originalUid) { + try { + await v.changeDirectory(originalUid ?? '/') + } catch { + /* best-effort */ + } + } + } +} + +async function runCd(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { + const target = parsed.positional[0] + if (!target) return { code: 1, out: '', err: `${cmd}: missing folder path. Usage: ${cmd} \n` } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'changeDirectory', cmd) + if (cap) return cap + try { + const res = await v.changeDirectory!(target) + return { code: 0, out: `${res.name}\n`, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError(`${cmd} ${target}`, e) } + } +} + +async function runTree(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'tree', cmd) + if (cap) return cap + await v.sync() + const folderPath = parsed.positional[0] + const out = await v.tree!(folderPath ? { folderPath, showRecords: true } : { showRecords: true }) + return { code: 0, out: out.endsWith('\n') ? out : out + '\n', err: '' } +} + +async function runMkdir(host: KeeperCliHost, parsed: ParsedCli, cmd: string): Promise { + const target = parsed.positional[0] + if (!target) { + return { code: 1, out: '', err: `${cmd}: missing path. Usage: ${cmd} [-sf]\n` } + } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'mkdir', cmd) + if (cap) return cap + const cwd = v.getWorkingFolderDisplayName?.() ?? 'My Vault' + const shared = + hasOpt(parsed.opts, 'shared-folder') || + hasOpt(parsed.opts, 'sf') || + hasOpt(parsed.opts, 'shared') + try { + const res = await v.mkdir!(target, { sharedFolder: shared }) + if (!res.success) { + return { code: 1, out: '', err: `${cmd} [in ${cwd}]: ${res.message ?? 'failed'}\n` } + } + return { code: 0, out: `${res.folderUid}\t${target} (in ${cwd})\n`, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError(`${cmd} ${target} [in ${cwd}]`, e) } + } +} + +const lsHelp: CliCommandDefinition['help'] = { + title: 'ls — list folder contents (Keeper Commander)', + synopsis: 'usage: ls [-l] [-f] [-r] [pattern]', + description: ' Lists records and subfolders in the current folder, or in PATH if given.', + options: ` -l, --list Detailed list (flags, types). + -f, --folders Folders only. + -r, --records Records only. + --help, -h Show this help.`, + examples: ' ls\n ls "Marketing"\n ls -l', + seeAlso: ' cd, tree, get', +} + +export const lsCommand: CliCommandDefinition = { + name: 'ls', + order: 11, + description: 'List folder contents (current folder or PATH).', + usage: 'ls [PATH] [-l|--list] [-f|--folders] [-r|--records]', + flagOptions: ['-l', '--list', '-f', '--folders', '-r', '--records', '--detail'], + help: lsHelp, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(lsCommand), err: '' } + try { + return await runLs(host, parsed, 'ls') + } catch (e) { + return { code: 1, out: '', err: host.formatError('ls', e) } + } + }, +} + +export const cdCommand: CliCommandDefinition = { + name: 'cd', + order: 12, + description: 'Change current folder.', + usage: 'cd ', + help: { + title: 'cd — change current folder (Keeper Commander)', + synopsis: 'usage: cd ', + description: ' PATH is a slash-separated folder name/UID sequence, or `/` for vault root.', + examples: ' cd Marketing\n cd ..\n cd /', + seeAlso: ' ls, tree', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(cdCommand), err: '' } + try { + return await runCd(host, parsed, 'cd') + } catch (e) { + return { code: 1, out: '', err: host.formatError('cd', e) } + } + }, +} + +export const treeCommand: CliCommandDefinition = { + name: 'tree', + order: 13, + description: 'Display the folder structure.', + usage: 'tree [PATH]', + help: { + title: 'tree — folder structure (Keeper Commander)', + synopsis: 'usage: tree [folder]', + description: + ' Renders an ASCII tree from PATH or the vault root. Each node is tagged [folder], [shared folder], or [record].', + seeAlso: ' ls, cd', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(treeCommand), err: '' } + try { + return await runTree(host, parsed, 'tree') + } catch (e) { + return { code: 1, out: '', err: host.formatError('tree', e) } + } + }, +} + +export const mkdirCommand: CliCommandDefinition = { + name: 'mkdir', + order: 14, + description: 'Create a folder.', + usage: 'mkdir [-sf|--shared-folder]', + flagOptions: ['-sf', '--shared-folder', '--shared'], + help: { + title: 'mkdir — create folder (Keeper Commander)', + synopsis: 'usage: mkdir [-sf]', + description: ' Creates a user folder under the current folder. -sf creates a shared folder.', + options: ' -sf, --shared-folder Create a shared folder.', + examples: ' mkdir Drafts\n mkdir TeamShare -sf', + seeAlso: ' cd, ls', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) return { code: 0, out: formatDetailedHelpForCommand(mkdirCommand), err: '' } + try { + return await runMkdir(host, parsed, 'mkdir') + } catch (e) { + return { code: 1, out: '', err: host.formatError('mkdir', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commands/help.ts b/KeeperSdk/src/cli/commands/help.ts new file mode 100644 index 00000000..24f49db6 --- /dev/null +++ b/KeeperSdk/src/cli/commands/help.ts @@ -0,0 +1,76 @@ +import type { CliCommandDefinition, KeeperCliHost } from '../types' +import { wantsCliHelp } from '../parse' +import { + formatAllCommandsSummary, + formatDetailedHelpForCommand, + formatShortCommandSummary, + getDetailedHelpPageForRegistry, +} from '../help' +import { isAuthCliCommand, listCliCommandsForLoginState } from '../access' +import { getCliCommand } from '../registry' + +export const helpCommand: CliCommandDefinition = { + name: 'help', + order: 0, + description: 'Show all commands, or full docs for one command (same as COMMAND --help).', + usage: 'help [command] (see also: help --help)', + help: { + title: 'help — show commands or short syntax for one command', + synopsis: ' help [COMMAND]', + description: ` Without arguments, lists commands for the current session. + When not logged in, only sign-in commands are listed (login, restore-session, …). + After login, lists vault commands as well. + + With COMMAND, prints usage for that command (sign-in commands only when logged out).`, + options: ' None. This command does not take GNU-style flags.', + seeAlso: ' Each command’s --help output.', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(helpCommand), err: '' } + } + if (parsed.opts.size > 0) { + return { code: 1, out: '', err: 'help: unknown option (try `help --help`)\n' } + } + const loggedIn = host.getVault().isLoggedIn + const visible = listCliCommandsForLoginState(loggedIn) + const args = parsed.positional + if (args.length === 0) { + if (loggedIn) { + return { code: 0, out: formatAllCommandsSummary(visible), err: '' } + } + return { + code: 0, + out: formatAllCommandsSummary(visible, { + header: 'Not logged in — sign-in commands:\n\n', + footer: + '\nRun `login`, `restore-session`, or `register-device` to open the vault.\n' + + 'After login, run `help` again for vault commands (get, ls, cd, …).\n', + }), + err: '', + } + } + if (args.length > 1) { + return { code: 1, out: '', err: 'Usage: help [command]\n' } + } + const name = args[0] + if (!loggedIn && !isAuthCliCommand(name)) { + return { + code: 1, + out: '', + err: + `help: "${name}" requires a logged-in session. ` + + 'Run `help` for sign-in commands (login, restore-session, register-device).\n', + } + } + const long = getDetailedHelpPageForRegistry(visible, name) + if (long) { + return { code: 0, out: long, err: '' } + } + const def = getCliCommand(name) + if (!def) { + return { code: 1, out: '', err: `help: unknown command: ${name}\n` } + } + return { code: 0, out: formatShortCommandSummary(def), err: '' } + }, +} diff --git a/KeeperSdk/src/cli/commands/login.ts b/KeeperSdk/src/cli/commands/login.ts new file mode 100644 index 00000000..25d9c294 --- /dev/null +++ b/KeeperSdk/src/cli/commands/login.ts @@ -0,0 +1,178 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt, rejectUnknownOptions, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { utf8ToBase64Url } from '../utils' + +const LOGIN_ALLOWED = new Set([ + 'username', + 'user', + 'session-token', + 'token', + 'st', + 'session-token-plain', +]) + +export async function runLoginCommand(host: KeeperCliHost, parsed?: ParsedCli): Promise { + const opts = parsed?.opts ?? new Map() + if (parsed && wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(loginCommand), err: '' } + } + if (parsed) { + for (const secretFlag of ['password', 'pass', 'pwd'] as const) { + if (opts.has(secretFlag)) { + return { + code: 1, + out: '', + err: + 'login: do not pass --password on the command line (it is logged and visible). ' + + 'Use KEEPER_PASSWORD for automation, or run `login --username …` in the shell and enter the password when prompted (masked).\n', + } + } + } + const bad = rejectUnknownOptions(parsed, LOGIN_ALLOWED, 'login') + if (bad) return bad + } + + const username = getOpt(opts, 'username', 'user') ?? host.envString('KEEPER_USERNAME') + const passwordEnv = host.envString('KEEPER_PASSWORD') + const sessionRaw = getOpt(opts, 'session-token', 'token', 'st') ?? host.envString('KEEPER_SESSION_TOKEN') + const sessionPlain = parsed && hasOpt(opts, 'session-token-plain') + + if (parsed) { + const stPlainVal = opts.get('session-token-plain') + if (stPlainVal !== undefined && stPlainVal !== true) { + return { + code: 1, + out: '', + err: 'login: --session-token-plain is a boolean flag (no value)\n', + } + } + } + + if (!username) { + return { + code: 1, + out: '', + err: 'login: provide --username or KEEPER_USERNAME.\n', + } + } + + const sessionTrimmed = typeof sessionRaw === 'string' ? sessionRaw.trim() : '' + if (sessionTrimmed.length > 0) { + return loginWithSessionToken(host, username, sessionTrimmed, { plainToken: !!sessionPlain }) + } + + if (!passwordEnv) { + return { + code: 1, + needPassword: true, + loginUsername: username, + out: '', + err: '', + } + } + + return loginWithCredentials(host, username, passwordEnv) +} + +export async function loginWithCredentials( + host: KeeperCliHost, + username: string, + password: string +): Promise { + try { + const v = host.getVault() + if (v.isLoggedIn) { + await v.logout() + } + await v.login(username, password) + await v.sync() + return { code: 0, out: `keeper: logged in as ${username}.\n`, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError('keeper', e) } + } +} + +export async function loginWithSessionToken( + host: KeeperCliHost, + username: string, + sessionToken: string, + options?: { plainToken?: boolean } +): Promise { + let token = sessionToken.trim() + if (options?.plainToken && token.length > 0) { + token = utf8ToBase64Url(token) + } + try { + const v = host.getVault() + if (v.isLoggedIn) { + await v.logout() + } + await v.loginWithSessionToken(username, token) + await v.sync() + return { code: 0, out: `keeper: logged in as ${username} (session token).\n`, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError('keeper', e) } + } +} + +/** Pass-through if logged in; auto-login when `KEEPER_USERNAME` is set; otherwise "not logged in". */ +export async function ensureLoggedIn(host: KeeperCliHost): Promise { + if (host.getVault().isLoggedIn) { + return { code: 0, out: '', err: '' } + } + if (host.envString('KEEPER_USERNAME')) { + return runLoginCommand(host, { positional: [], opts: new Map() }) + } + return { code: 1, out: '', err: 'not logged in\n' } +} + +export const loginCommand: CliCommandDefinition = { + name: 'login', + order: 10, + description: + 'Log in with password (env / masked prompt) or session token (flag or KEEPER_SESSION_TOKEN). Password never on CLI line.', + usage: + 'login [--username|--user ] [--session-token|--token|--st ] [--session-token-plain] [--help|-h]', + flagOptions: [ + '--user', + '--username', + '--session-token', + '--token', + '--st', + '--session-token-plain', + ], + allowedOptions: LOGIN_ALLOWED, + help: { + title: 'login — authenticate to Keeper (vault session)', + synopsis: ` login [--username|--user EMAIL_OR_NAME] + login [--username|--user U] [--session-token|--token|--st TOKEN] + login [--username|--user U] [--session-token TOKEN] [--session-token-plain]`, + description: ` Establishes a Keeper session. + + Username comes from --username / --user or KEEPER_USERNAME. + + Password MUST NOT appear on the CLI line (logging, proxies, browser history). + Automation: set KEEPER_PASSWORD in the environment when embedding in Node. + Web shell: run login with only a username; the UI prompts for a masked password + and sends it through the login transport, not in "line". + + Session token login: pass the token on the command line or via + KEEPER_SESSION_TOKEN (sensitive — same caveats as any secret on argv). + + --session-token-plain treats the value as plain UTF-8 and encodes base64url + before login (same idea as the session_token_login example). + + Device registration: session token login requires deviceToken + privateKey for + this host in session storage. Use register-device (or a prior password login in + this shell) to store them; see register-device --help.`, + options: ` --username, --user Account identifier (often email). + --session-token, --token, --st Session token string (or use KEEPER_SESSION_TOKEN). + --session-token-plain Treat --session-token value as plain UTF-8 and encode base64url.`, + environment: ` KEEPER_USERNAME Default username if not passed on the command line. + KEEPER_PASSWORD Password for non-interactive login (no session token). + KEEPER_SESSION_TOKEN Session token when not passed as a flag. + KEEPER_HOST Optional vault host / region (also: keeper-host attribute).`, + }, + run: (host, parsed) => runLoginCommand(host, parsed), +} diff --git a/KeeperSdk/src/cli/commands/logout.ts b/KeeperSdk/src/cli/commands/logout.ts new file mode 100644 index 00000000..aa70924a --- /dev/null +++ b/KeeperSdk/src/cli/commands/logout.ts @@ -0,0 +1,39 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' + +export async function runLogoutCommand(host: KeeperCliHost, parsed?: ParsedCli): Promise { + if (parsed && wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(logoutCommand), err: '' } + } + if (parsed && parsed.opts.size > 0) { + return { code: 1, out: '', err: 'logout: no options (try: logout --help)\n' } + } + if (parsed && parsed.positional.length > 0) { + return { code: 1, out: '', err: 'Usage: logout\n' } + } + try { + const v = host.getVault() + if (!v.isLoggedIn) { + return { code: 0, out: 'keeper: already logged out.\n', err: '' } + } + await v.logout() + return { code: 0, out: 'keeper: logged out.\n', err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError('keeper', e) } + } +} + +export const logoutCommand: CliCommandDefinition = { + name: 'logout', + order: 200, + description: 'Log out of the current Keeper session.', + usage: 'logout [--help|-h]', + help: { + title: 'logout — end the current Keeper session', + synopsis: ' logout', + description: ' Ends the current session if one exists.', + options: ' None.', + }, + run: (host, parsed) => runLogoutCommand(host, parsed), +} diff --git a/KeeperSdk/src/cli/commands/registerDevice.ts b/KeeperSdk/src/cli/commands/registerDevice.ts new file mode 100644 index 00000000..b7f2d5c8 --- /dev/null +++ b/KeeperSdk/src/cli/commands/registerDevice.ts @@ -0,0 +1,90 @@ +import type { CliCommandDefinition, KeeperCliHost } from '../types' +import { getOpt, rejectUnknownOptions, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' + +const REGISTER_DEVICE_ALLOWED = new Set(['device-token', 'dt', 'private-key', 'pk', 'username', 'user']) + +export const registerDeviceCommand: CliCommandDefinition = { + name: 'register-device', + order: 15, + description: + 'Store device token + private key in this shell’s session so session-token login works (see login --help).', + usage: + 'register-device --device-token|--dt --private-key|--pk [--username ] [--help|-h]', + flagOptions: [ + '--device-token', + '--dt', + '--private-key', + '--pk', + '--user', + '--username', + ], + allowedOptions: REGISTER_DEVICE_ALLOWED, + help: { + title: 'register-device — store device token and private key for session-token login', + synopsis: + ' register-device --device-token|--dt B64 --private-key|--pk B64 [--username|--user U]', + description: ` Saves device credentials for the current host in this shell’s in-memory + session. After this, you can run: + + login --username YOU --session-token TOKEN + + without a prior password login in this shell, as long as the token is valid. + + Obtain device_token and private_key from another machine’s keeper config after + a successful login, or from your integration that provisions device keys. + Values accept base64 or base64url (same decoding as SessionManager / normal64Bytes).`, + options: ` --device-token, --dt Device token string. + --private-key, --pk Device private key string. + --username, --user Optional; sets last username in session storage (recommended).`, + environment: ` REGISTER_DEVICE_TOKEN Same as --device-token when flag omitted. + REGISTER_DEVICE_PRIVATE_KEY Same as --private-key when flag omitted. + KEEPER_HOST Same as other keeper commands.`, + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(registerDeviceCommand), err: '' } + } + const bad = rejectUnknownOptions(parsed, REGISTER_DEVICE_ALLOWED, 'register-device') + if (bad) return bad + if (parsed.positional.length > 0) { + return { + code: 1, + out: '', + err: 'register-device: unexpected positional arguments\n', + } + } + + const deviceToken = + getOpt(parsed.opts, 'device-token', 'dt') ?? host.envString('REGISTER_DEVICE_TOKEN') + const privateKey = + getOpt(parsed.opts, 'private-key', 'pk') ?? host.envString('REGISTER_DEVICE_PRIVATE_KEY') + const usernameOpt = getOpt(parsed.opts, 'username', 'user') + const username = usernameOpt?.trim() || undefined + + const dt = typeof deviceToken === 'string' ? deviceToken.trim() : '' + const pk = typeof privateKey === 'string' ? privateKey.trim() : '' + if (!dt || !pk) { + return { + code: 1, + out: '', + err: + 'register-device: --device-token and --private-key required ' + + '(or REGISTER_DEVICE_TOKEN / REGISTER_DEVICE_PRIVATE_KEY env).\n', + } + } + + try { + const v = host.getVault() + await v.registerDevice(dt, pk, username ? { username } : undefined) + const umsg = username ? ` Last username set to ${username}.` : '' + return { + code: 0, + out: `keeper: device credentials stored in this shell’s session.${umsg} Next: login --username … --session-token …\n`, + err: '', + } + } catch (e) { + return { code: 1, out: '', err: host.formatError('register-device', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commands/restoreSession.ts b/KeeperSdk/src/cli/commands/restoreSession.ts new file mode 100644 index 00000000..7e416d59 --- /dev/null +++ b/KeeperSdk/src/cli/commands/restoreSession.ts @@ -0,0 +1,211 @@ +import type { CliCommandDefinition, KeeperCliHost, ParsedCli } from '../types' +import { + resolveSessionRestorePayload, + validateSessionRestoreInput, + type SessionRestoreInput, +} from '../../auth/sessionRestore' +import { getOpt, hasOpt, rejectUnknownOptions, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { runVaultSync } from './sync' + +/** Flags allowed to follow `--from-json ` on the same line. */ +export const RESTORE_SESSION_TRAILING_OPTS = [ + 'sync', + 'account-uid', + 'client-key', + 'data-key', + 'ecc-private-key', + 'ecc-public-key', + 'message-session-uid', + 'private-key', + 'session-token', + 'st', + 'session-token-type', + 'username', + 'user', + 'user-type', + 'sso-logout-url', + 'sso-session-id', + 'enterprise-public-key', + 'enterprise-ecc-public-key', +] as const + +const RESTORE_ALLOWED = new Set([ + 'sync', + 'from-json', + 'account-uid', + 'client-key', + 'data-key', + 'ecc-private-key', + 'ecc-public-key', + 'message-session-uid', + 'private-key', + 'session-token', + 'st', + 'session-token-type', + 'username', + 'user', + 'user-type', + 'sso-logout-url', + 'sso-session-id', + 'enterprise-public-key', + 'enterprise-ecc-public-key', +]) + +const ENV_PREFIX = 'RESTORE_SESSION_' + +const FIELD_ENV: Record = { + ACCOUNT_UID: 'accountUid', + CLIENT_KEY: 'clientKey', + DATA_KEY: 'dataKey', + ECC_PRIVATE_KEY: 'eccPrivateKey', + ECC_PUBLIC_KEY: 'eccPublicKey', + MESSAGE_SESSION_UID: 'messageSessionUid', + PRIVATE_KEY: 'privateKey', + SESSION_TOKEN: 'sessionToken', + SESSION_TOKEN_TYPE: 'sessionTokenType', + USERNAME: 'username', + USER_TYPE: 'userType', + SSO_LOGOUT_URL: 'ssoLogoutUrl', + SSO_SESSION_ID: 'ssoSessionId', + ENTERPRISE_PUBLIC_KEY: 'enterprisePublicKey', + ENTERPRISE_ECC_PUBLIC_KEY: 'enterpriseEccPublicKey', +} + +const OPT_TO_FIELD: Record = { + 'account-uid': 'accountUid', + 'client-key': 'clientKey', + 'data-key': 'dataKey', + 'ecc-private-key': 'eccPrivateKey', + 'ecc-public-key': 'eccPublicKey', + 'message-session-uid': 'messageSessionUid', + 'private-key': 'privateKey', + 'session-token': 'sessionToken', + st: 'sessionToken', + 'session-token-type': 'sessionTokenType', + username: 'username', + user: 'username', + 'user-type': 'userType', + 'sso-logout-url': 'ssoLogoutUrl', + 'sso-session-id': 'ssoSessionId', + 'enterprise-public-key': 'enterprisePublicKey', + 'enterprise-ecc-public-key': 'enterpriseEccPublicKey', +} + +function envField(host: KeeperCliHost, key: keyof SessionRestoreInput): string | undefined { + const envKey = Object.entries(FIELD_ENV).find(([, v]) => v === key)?.[0] + return envKey ? host.envString(`${ENV_PREFIX}${envKey}`) : undefined +} + +function buildInputFromFlags(host: KeeperCliHost, parsed: ParsedCli): SessionRestoreInput { + const partial: Partial = {} + + for (const [opt, field] of Object.entries(OPT_TO_FIELD)) { + const fromFlag = getOpt(parsed.opts, opt) + if (fromFlag !== undefined) { + ;(partial as Record)[field] = fromFlag + continue + } + const fromEnv = envField(host, field) + if (fromEnv !== undefined) { + ;(partial as Record)[field] = fromEnv + } + } + + return validateSessionRestoreInput(partial) +} + +export const restoreSessionCommand: CliCommandDefinition = { + name: 'restore-session', + order: 12, + description: + 'Restore a logged-in session from extension SessionParams (continueSession; no device keys required).', + usage: + 'restore-session --from-json FILE|JSON [--sync] OR restore-session --session-token … (see --help)', + flagOptions: [ + '--sync', + '--from-json', + '--account-uid', + '--client-key', + '--data-key', + '--ecc-private-key', + '--ecc-public-key', + '--message-session-uid', + '--private-key', + '--session-token', + '--session-token-type', + '--username', + '--user-type', + '--sso-logout-url', + '--sso-session-id', + '--enterprise-public-key', + '--enterprise-ecc-public-key', + ], + allowedOptions: RESTORE_ALLOWED, + help: { + title: 'restore-session — restore SessionParams from extension / vault export', + synopsis: ` restore-session --from-json session.json + restore-session --session-token TOKEN --username U --account-uid B64 …`, + description: ` Loads a full SessionParams snapshot and resumes the session (same path as + the browser extension after login). Use this when you have accountUid, + clientKey, dataKey, keys, sessionToken, username, etc. from extension storage + — deviceToken/device private key are not part of this payload. + + Provide parameters either as one JSON object (--from-json) or as flags / env. + Binary fields are base64 or base64url.`, + options: ` --from-json Inline JSON (object or JSON-stringified object), or a file path + The entire remainder of the command line is passed to JSON.parse (then file read if needed). + --account-uid, --client-key, --data-key, --ecc-private-key, --ecc-public-key + --message-session-uid, --private-key + --session-token, --st Session token string (as stored; often base64url) + --session-token-type Numeric SessionTokenType enum + --username, --user + --user-type 0=normal, 1=cloud_sso, 2=onsite_sso (or string names) + --sso-logout-url, --sso-session-id + --enterprise-public-key, --enterprise-ecc-public-key (optional) + --sync Run syncDown after restoring the session`, + environment: ` RESTORE_SESSION_JSON Same as --from-json + RESTORE_SESSION_ACCOUNT_UID Per-field overrides (see --help flags) + RESTORE_SESSION_SESSION_TOKEN + … (RESTORE_SESSION_ for each field above)`, + note: ' sessionToken expires; region must match keeper-host / KEEPER_HOST.', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(restoreSessionCommand), err: '' } + } + const bad = rejectUnknownOptions(parsed, RESTORE_ALLOWED, 'restore-session') + if (bad) return bad + if (parsed.positional.length > 0) { + return { code: 1, out: '', err: 'restore-session: unexpected positional arguments\n' } + } + + try { + let input: SessionRestoreInput + const jsonRaw = getOpt(parsed.opts, 'from-json') ?? host.envString('RESTORE_SESSION_JSON') + if (jsonRaw) { + const readFile = + host.readTextFile ?? + (typeof document === 'undefined' + ? async (path: string) => (await import('fs/promises')).readFile(path, 'utf8') + : undefined) + input = await resolveSessionRestorePayload(jsonRaw, readFile) + } else { + input = buildInputFromFlags(host, parsed) + } + + await host.getVault().restoreSession(input) + let out = `keeper: session restored for ${input.username}.\n` + if (hasOpt(parsed.opts, 'sync')) { + const syncResult = await runVaultSync(host) + if (syncResult.code !== 0) { + return syncResult + } + out += syncResult.out + } + return { code: 0, out, err: '' } + } catch (e) { + return { code: 1, out: '', err: host.formatError('restore-session', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commands/sync.ts b/KeeperSdk/src/cli/commands/sync.ts new file mode 100644 index 00000000..72d71f67 --- /dev/null +++ b/KeeperSdk/src/cli/commands/sync.ts @@ -0,0 +1,61 @@ +import type { SyncResult } from '@keeper-security/keeperapi' +import type { CliCommandDefinition, CliResult, KeeperCliHost } from '../types' +import { wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureLoggedIn } from './login' + +function formatSyncSummary(result: SyncResult): string { + const lines = [`keeper: sync complete for ${result.username}.`, ` pages: ${result.pageCount}`] + if (result.totalTime) lines.push(` total: ${result.totalTime}`) + if (result.networkTime) lines.push(` network: ${result.networkTime}`) + const counts = result.counts ?? {} + const parts = Object.entries(counts) + .filter(([, n]) => typeof n === 'number' && n > 0) + .map(([k, n]) => `${k}=${n}`) + if (parts.length) lines.push(` counts: ${parts.join(', ')}`) + if (result.error) lines.push(` warning: ${result.error}`) + return lines.join('\n') + '\n' +} + +/** Download vault data via keeperapi syncDown (KeeperVault.sync). */ +export async function runVaultSync(host: KeeperCliHost): Promise { + const v = host.getVault() + if (!v.isLoggedIn) { + const login = await ensureLoggedIn(host) + if (login.code !== 0) return login + } + const result = await v.sync() + return { code: 0, out: formatSyncSummary(result), err: '' } +} + +export const syncCommand: CliCommandDefinition = { + name: 'sync', + order: 20, + aliases: ['syncdown', 'sync-down', 'd'], + description: 'Download / refresh vault data from Keeper (syncDown).', + usage: 'sync [--help|-h]', + help: { + title: 'sync — download vault data (syncDown)', + synopsis: ' sync', + description: ` Pulls records, folders, and related vault data into local storage. + Requires an active session (login or restore-session).`, + options: ' --help, -h Show this help.', + seeAlso: ' restore-session --sync, list, ls', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(syncCommand), err: '' } + } + if (parsed.opts.size > 0) { + return { code: 1, out: '', err: 'sync: unknown option (try: sync --help)\n' } + } + if (parsed.positional.length > 0) { + return { code: 1, out: '', err: 'sync: unexpected arguments (try: sync --help)\n' } + } + try { + return await runVaultSync(host) + } catch (e) { + return { code: 1, out: '', err: host.formatError('sync', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commands/users.ts b/KeeperSdk/src/cli/commands/users.ts new file mode 100644 index 00000000..032b7660 --- /dev/null +++ b/KeeperSdk/src/cli/commands/users.ts @@ -0,0 +1,115 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { getOpt, hasOpt, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureCapability, ensureSession } from '../commandHelpers' +import { + formatUsersTable, + renderUsersAsciiTable, + SUPPORTED_USER_COLUMNS, +} from '../../users/listUsers' +import type { UserColumnInput } from '../../users/userTypes' +import { formatUserView, userViewTable } from '../../users/viewUser' + +const SUBCOMMANDS = ['list', 'view'] as const +type Sub = (typeof SUBCOMMANDS)[number] + +function parseColumns(raw: string | undefined): UserColumnInput[] | '*' | undefined { + const trimmed = raw?.trim() + if (!trimmed) return undefined + if (trimmed === '*') return '*' + return trimmed + .split(',') + .map((p) => p.trim()) + .filter((p) => p.length > 0) as UserColumnInput[] +} + +async function runList(host: KeeperCliHost, parsed: ParsedCli): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'listUsers', 'users list') + if (cap) return cap + await v.sync!() + const pattern = getOpt(parsed.opts, 'pattern') ?? null + const columns = parseColumns(getOpt(parsed.opts, 'columns')) + const rows = await v.listUsers!({ pattern, columns }) + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(rows, null, 2) + '\n', err: '' } + } + if (rows.length === 0) { + return { + code: 0, + out: pattern ? `(no users matched "${pattern}")\n` : '(no users)\n', + err: '', + } + } + const table = formatUsersTable(rows, { columns }) + return { code: 0, out: renderUsersAsciiTable(table) + '\n', err: '' } +} + +async function runView(host: KeeperCliHost, parsed: ParsedCli): Promise { + const id = parsed.positional[1] + if (!id) { + return { code: 1, out: '', err: 'users view: missing email or user id. Usage: users view \n' } + } + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'viewUser', 'users view') + if (cap) return cap + const view = await v.viewUser!(id) + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(view, null, 2) + '\n', err: '' } + } + const formatted = formatUserView(view, { verbose: hasOpt(parsed.opts, 'verbose') }) + return { code: 0, out: userViewTable(formatted) + '\n', err: '' } +} + +export const usersCommand: CliCommandDefinition = { + name: 'users', + order: 41, + description: 'Enterprise users (list, view).', + usage: 'users list|view [args] [--pattern P] [--columns C] [--json] [--verbose] [--help|-h]', + subcommands: [...SUBCOMMANDS], + flagOptions: ['--pattern', '--columns', '--json', '--verbose'], + help: { + title: 'users — list and inspect enterprise users', + synopsis: ` users list [--pattern P] [--columns cols] + users view EMAIL|ID [--json] [--verbose]`, + description: ' Requires an enterprise account.', + arguments: ` list Table of enterprise users. + view Details for one user by email or enterprise_user_id.`, + options: ` --pattern list only: filter by name/email substring. + --columns list only: comma-separated columns or * (supported: ${SUPPORTED_USER_COLUMNS.join(', ')}). + --json Emit JSON instead of a table. + --verbose view only: include UIDs in team/role rows. + --help, -h Show this help.`, + examples: ` users list + users list --pattern @acme.com --columns name,status,node + users view user@example.com`, + seeAlso: ' list-team, whoami, sync, login', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(usersCommand), err: '' } + } + const sub = (parsed.positional[0]?.toLowerCase() ?? 'list') as Sub + if (!SUBCOMMANDS.includes(sub)) { + return { + code: 1, + out: '', + err: `users: unknown subcommand "${parsed.positional[0]}". Try: users --help\n`, + } + } + try { + switch (sub) { + case 'list': + return await runList(host, parsed) + case 'view': + return await runView(host, parsed) + } + } catch (e) { + return { code: 1, out: '', err: host.formatError(`users ${sub}`, e) } + } + }, +} diff --git a/KeeperSdk/src/cli/commands/vault.ts b/KeeperSdk/src/cli/commands/vault.ts new file mode 100644 index 00000000..d3c25482 --- /dev/null +++ b/KeeperSdk/src/cli/commands/vault.ts @@ -0,0 +1,56 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from '../types' +import { hasOpt, wantsCliHelp } from '../parse' +import { formatDetailedHelpForCommand } from '../help' +import { ensureCapability, ensureSession } from '../commandHelpers' + +async function runSummary(host: KeeperCliHost, parsed: ParsedCli): Promise { + const r = await ensureSession(host) + if (r) return r + const v = host.getVault() + const cap = ensureCapability(v, 'getSummary', 'vault summary') + if (cap) return cap + await v.sync!() + const summary = v.getSummary!() + if (hasOpt(parsed.opts, 'json')) { + return { code: 0, out: JSON.stringify(summary, null, 2) + '\n', err: '' } + } + const lines = [ + `records: ${summary.recordCount}`, + `shared_folders: ${summary.sharedFolderCount}`, + `teams: ${summary.teamCount}`, + `folders: ${summary.folderCount}`, + ] + return { code: 0, out: lines.join('\n') + '\n', err: '' } +} + +export const vaultCommand: CliCommandDefinition = { + name: 'vault', + order: 25, + description: 'Vault summary counts (records, folders, teams, …).', + usage: 'vault summary [--json] [--help|-h]', + subcommands: ['summary'], + flagOptions: ['--json'], + help: { + title: 'vault — vault-wide statistics', + synopsis: ' vault summary [--json]', + description: ' Runs sync, then prints counts from the local vault cache.', + arguments: ' summary Print record, shared folder, team, and user-folder counts.', + options: ' --json Emit JSON.\n --help, -h Show this help.', + examples: ' vault summary\n vault summary --json', + seeAlso: ' sync, list, tree, whoami', + }, + async run(host, parsed) { + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(vaultCommand), err: '' } + } + const sub = parsed.positional[0]?.toLowerCase() ?? 'summary' + if (sub !== 'summary') { + return { code: 1, out: '', err: 'Usage: vault summary\n' } + } + try { + return await runSummary(host, parsed) + } catch (e) { + return { code: 1, out: '', err: host.formatError('vault summary', e) } + } + }, +} diff --git a/KeeperSdk/src/cli/dispatch.ts b/KeeperSdk/src/cli/dispatch.ts new file mode 100644 index 00000000..b3be1427 --- /dev/null +++ b/KeeperSdk/src/cli/dispatch.ts @@ -0,0 +1,52 @@ +import type { CliResult, KeeperCliHost, ParsedCli } from './types' +import { parseCliArgs, tokenizeArguments, wantsCliHelp } from './parse' +import { extractFromJsonFlagValue } from './jsonArg' +import { RESTORE_SESSION_TRAILING_OPTS } from './commands/restoreSession' +import { formatDetailedHelpForCommand } from './help' +import { isAuthCliCommand } from './access' +import { getCliCommand } from './registry' + +const NOT_LOGGED_IN_ERR = + 'Not logged in. Run `login`, `restore-session`, or `register-device` (see `help`).\n' + +export async function dispatchKeeperCli( + commandName: string, + args: string[], + host: KeeperCliHost, + preParsed?: ParsedCli +): Promise { + const def = getCliCommand(commandName) + if (!def) { + return { code: 1, out: '', err: `Unknown command: ${commandName}\n` } + } + if (!host.getVault().isLoggedIn && !isAuthCliCommand(def.name)) { + return { code: 1, out: '', err: NOT_LOGGED_IN_ERR } + } + const parsed = preParsed ?? parseCliArgs(args) + if (wantsCliHelp(parsed)) { + return { code: 0, out: formatDetailedHelpForCommand(def), err: '' } + } + return def.run(host, parsed) +} + +export async function dispatchCliLine(line: string, host: KeeperCliHost): Promise { + const trimmed = line.trim() + if (!trimmed) { + return { code: 0, out: '', err: '' } + } + const tokens = tokenizeArguments(trimmed) + const name = tokens[0]?.toLowerCase() + if (!name) { + return { code: 0, out: '', err: '' } + } + const args = tokens.slice(1) + let preParsed: ParsedCli | undefined + if (name === 'restore-session') { + const json = extractFromJsonFlagValue(trimmed, 'from-json', RESTORE_SESSION_TRAILING_OPTS) + if (json) { + preParsed = parseCliArgs(args) + preParsed.opts.set('from-json', json) + } + } + return dispatchKeeperCli(name, args, host, preParsed) +} diff --git a/KeeperSdk/src/cli/help.ts b/KeeperSdk/src/cli/help.ts new file mode 100644 index 00000000..40773a4f --- /dev/null +++ b/KeeperSdk/src/cli/help.ts @@ -0,0 +1,82 @@ +import type { CliCommandDefinition, CliHelpDoc } from './types' + +const SECTION_ORDER: (keyof CliHelpDoc)[] = [ + 'synopsis', + 'description', + 'arguments', + 'options', + 'environment', + 'examples', + 'seeAlso', + 'note', +] + +const SECTION_LABELS: Partial> = { + synopsis: 'SYNOPSIS', + description: 'DESCRIPTION', + arguments: 'ARGUMENTS', + options: 'OPTIONS', + environment: 'ENVIRONMENT', + examples: 'EXAMPLES', + seeAlso: 'SEE ALSO', + note: 'NOTE', +} + +export function formatDetailedHelp(doc: CliHelpDoc): string { + const parts: string[] = [doc.title.trim()] + for (const key of SECTION_ORDER) { + const body = doc[key] + if (typeof body !== 'string' || !body.trim()) continue + const label = SECTION_LABELS[key] + if (label) { + parts.push('') + parts.push(label) + } + parts.push(body.trim()) + } + return `${parts.join('\n')}\n` +} + +export function formatDetailedHelpForCommand(def: CliCommandDefinition): string { + return formatDetailedHelp(def.help) +} + +export function getDetailedHelpPageForRegistry( + commands: Iterable, + name: string +): string | null { + const key = name.toLowerCase() + for (const def of commands) { + if (def.name === key) return formatDetailedHelpForCommand(def) + if (def.aliases?.some((a) => a.toLowerCase() === key)) { + return formatDetailedHelpForCommand(def) + } + } + return null +} + +export type CommandsSummaryOptions = { + header?: string + footer?: string +} + +export function formatAllCommandsSummary( + commands: readonly CliCommandDefinition[], + options?: CommandsSummaryOptions +): string { + const sorted = [...commands].sort((a, b) => a.name.localeCompare(b.name)) + const w = Math.max(...sorted.map((c) => c.name.length), 8) + let out = options?.header ?? 'Supported commands:\n\n' + if (!out.endsWith('\n\n')) { + out = out.endsWith('\n') ? `${out}\n` : `${out}\n\n` + } + for (const c of sorted) { + out += ` ${c.name.padEnd(w)} ${c.description}\n` + } + out += options?.footer ?? '\nRun ` --help` (or `-h`) for details on a specific command.\n' + return out +} + +export function formatShortCommandSummary(def: CliCommandDefinition): string { + return `${def.name} — ${def.description}\n Usage: ${def.usage}\n` +} diff --git a/KeeperSdk/src/cli/index.ts b/KeeperSdk/src/cli/index.ts new file mode 100644 index 00000000..8d150d61 --- /dev/null +++ b/KeeperSdk/src/cli/index.ts @@ -0,0 +1,112 @@ +import { registerBuiltinCliCommands } from './builtinCommands' +import { registerCliAlias } from './registry' + +let registryInitialized = false + +/** Register built-in Keeper CLI commands (idempotent). */ +export function ensureKeeperCliRegistry(): void { + if (registryInitialized) return + registryInitialized = true + registerBuiltinCliCommands() + registerCliAlias('?', 'help') +} + +ensureKeeperCliRegistry() + +export type { + CliResult, + ParsedCli, + CliHelpDoc, + CliCommandDefinition, + KeeperCliHost, + KeeperCliVault, +} from './types' + +export { + tokenizeArguments, + parseCliArgs, + hasOpt, + getOpt, + wantsCliHelp, + rejectUnknownOptions, +} from './parse' + +export { + formatDetailedHelp, + formatDetailedHelpForCommand, + formatAllCommandsSummary, + formatShortCommandSummary, +} from './help' +import { getDetailedHelpPageForRegistry } from './help' +import { listCliCommands } from './registry' + +export function getDetailedHelpPage(name: string): string | null { + ensureKeeperCliRegistry() + return getDetailedHelpPageForRegistry(listCliCommands(), name) +} + +export { + registerCliCommand, + registerCliAlias, + resolveCliCommandName, + getCliCommand, + listCliCommands, + listCliCommandNames, + listDocumentedCommands, + clearCliRegistry, +} from './registry' + +export { + AUTH_CLI_COMMAND_NAMES, + isAuthCliCommand, + filterCliCommandsForLoginState, + listCliCommandsForLoginState, + listCliCommandNamesForLoginState, +} from './access' + +export { dispatchKeeperCli, dispatchCliLine } from './dispatch' + +export { KeeperCliParser, createKeeperCliParser } from './parser' +export type { KeeperCliParserOptions } from './parser' + +export { + runLoginCommand, + loginWithCredentials, + loginWithSessionToken, + ensureLoggedIn, + loginCommand, +} from './commands/login' + +export { runLogoutCommand, logoutCommand } from './commands/logout' +export { usersCommand } from './commands/users' +export { vaultCommand } from './commands/vault' +export { registerDeviceCommand } from './commands/registerDevice' +export { helpCommand } from './commands/help' +export { restoreSessionCommand } from './commands/restoreSession' +export { syncCommand, runVaultSync } from './commands/sync' + +export { getKeeperCliPromptPrefix } from './prompt' +export { BUILTIN_CLI_COMMANDS, registerBuiltinCliCommands } from './builtinCommands' +export { + getCommand, + executeGet, + listCommand, + searchCommand, + listSfCommand, + listTeamCommand, + whoamiCommand, + lsCommand, + cdCommand, + treeCommand, + mkdirCommand, +} from './commander' + +export { utf8ToBase64Url, recordUid } from './utils' + +export type { SessionRestoreInput } from '../auth/sessionRestore' +export { + toSessionParams, + validateSessionRestoreInput, + sessionRestoreFromJson, + resolveSessionRestorePayload, +} from '../auth/sessionRestore' diff --git a/KeeperSdk/src/cli/jsonArg.ts b/KeeperSdk/src/cli/jsonArg.ts new file mode 100644 index 00000000..6a11c1bc --- /dev/null +++ b/KeeperSdk/src/cli/jsonArg.ts @@ -0,0 +1,30 @@ +/** + * Everything after `--from-json` on the command line (trimmed). No tokenization — callers use JSON.parse. + * Trailing flags such as `--sync` are stripped via {@link stripTrailingCliFlags}. + */ +export function extractFromJsonFlagValue( + line: string, + flag = 'from-json', + trailingFlags: readonly string[] = [] +): string | null { + const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const flagRe = new RegExp(`(?:^|\\s)--${escaped}(?:\\s*=\\s*|\\s+)`, 'i') + const m = line.match(flagRe) + if (!m || m.index === undefined) return null + const rest = line.slice(m.index + m[0].length).trim() + if (!rest) return null + return stripTrailingCliFlags(rest, trailingFlags) +} + +/** Remove trailing ` --flag` tokens (e.g. `--sync` after a file path or JSON blob). */ +export function stripTrailingCliFlags(value: string, flagNames: readonly string[]): string { + if (flagNames.length === 0) return value.trim() + let s = value.trim() + const parts = flagNames.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + const alt = parts.join('|') + const tailFlag = new RegExp(`\\s+--(?:${alt})(?:\\s*=\\s*(?:"[^"]*"|\\S+))?\\s*$`, 'i') + while (tailFlag.test(s)) { + s = s.replace(tailFlag, '').trim() + } + return s +} diff --git a/KeeperSdk/src/cli/parse.ts b/KeeperSdk/src/cli/parse.ts new file mode 100644 index 00000000..73f9ef15 --- /dev/null +++ b/KeeperSdk/src/cli/parse.ts @@ -0,0 +1,173 @@ +import type { CliResult, ParsedCli } from './types' + +const isWhitespace = (ch: string) => /\s/.test(ch) + +/** Split a command line into tokens; respects double quotes and `\\` escapes. */ +export function tokenizeArguments(args: string): string[] { + const out: string[] = [] + const sb: string[] = [] + let pos = 0 + let inQuote = false + let escape = false + + const flush = () => { + if (sb.length > 0) { + out.push(sb.join('')) + sb.length = 0 + } + } + + while (pos < args.length) { + const ch = args[pos] + if (escape) { + escape = false + sb.push(ch) + pos++ + continue + } + if (inQuote) { + if (ch === '\\') { + escape = true + pos++ + continue + } + if (ch === '"') { + inQuote = false + pos++ + continue + } + sb.push(ch) + pos++ + continue + } + switch (ch) { + case '\\': + escape = true + pos++ + break + case '"': + inQuote = true + pos++ + break + default: + if (isWhitespace(ch)) { + flush() + pos++ + } else { + sb.push(ch) + pos++ + } + } + } + flush() + return out +} + +function setBool(opts: Map, k: string): void { + opts.set(k.toLowerCase(), true) +} + +function setStr(opts: Map, k: string, v: string): void { + opts.set(k.toLowerCase(), v) +} + +/** Parse argv-style tokens after the command name. */ +export function parseCliArgs(tokens: string[]): ParsedCli { + const positional: string[] = [] + const opts = new Map() + + let i = 0 + while (i < tokens.length) { + const t = tokens[i] + if (t === '--') { + positional.push(...tokens.slice(i + 1)) + break + } + if (t === '-' || !t.startsWith('-')) { + positional.push(t) + i++ + continue + } + + if (t.startsWith('--')) { + const body = t.slice(2) + if (!body) { + positional.push(t) + i++ + continue + } + const eq = body.indexOf('=') + if (eq >= 0) { + setStr(opts, body.slice(0, eq), body.slice(eq + 1)) + i++ + continue + } + const name = body + const next = tokens[i + 1] + if (next && next !== '--' && !next.startsWith('-')) { + setStr(opts, name, next) + i += 2 + continue + } + setBool(opts, name) + i++ + continue + } + + const rest = t.slice(1) + if (!rest) { + positional.push(t) + i++ + continue + } + if (/^[A-Za-z]$/.test(rest)) { + setBool(opts, rest) + i++ + continue + } + if (/^[A-Za-z]+$/.test(rest)) { + for (const ch of rest) setBool(opts, ch) + i++ + continue + } + + positional.push(t) + i++ + } + + return { positional, opts } +} + +export function hasOpt(opts: Map, ...names: string[]): boolean { + for (const n of names) { + const v = opts.get(n.toLowerCase()) + if (v === true) return true + } + return false +} + +export function getOpt(opts: Map, ...names: string[]): string | undefined { + for (const n of names) { + const v = opts.get(n.toLowerCase()) + if (v !== undefined && v !== true) return v + } + return undefined +} + +export function wantsCliHelp(parsed: ParsedCli): boolean { + return hasOpt(parsed.opts, 'help', 'h') +} + +export function rejectUnknownOptions( + parsed: ParsedCli, + allowed: ReadonlySet, + commandName: string +): CliResult | null { + for (const k of parsed.opts.keys()) { + if (k === 'help' || k === 'h') continue + if (!allowed.has(k)) { + return { code: 1, out: '', err: `${commandName}: unknown option --${k}\n` } + } + } + return null +} diff --git a/KeeperSdk/src/cli/parser.ts b/KeeperSdk/src/cli/parser.ts new file mode 100644 index 00000000..7fa74311 --- /dev/null +++ b/KeeperSdk/src/cli/parser.ts @@ -0,0 +1,172 @@ +import type { CliCommandDefinition, CliResult, KeeperCliHost, ParsedCli } from './types' +import { parseCliArgs, tokenizeArguments, wantsCliHelp } from './parse' +import { extractFromJsonFlagValue } from './jsonArg' +import { RESTORE_SESSION_TRAILING_OPTS } from './commands/restoreSession' +import { AUTH_CLI_COMMAND_NAMES, isAuthCliCommand } from './access' +import { BUILTIN_CLI_COMMANDS } from './builtinCommands' +import { formatAllCommandsSummary, formatDetailedHelpForCommand, formatShortCommandSummary } from './help' + +const NOT_LOGGED_IN_ERR = + 'Not logged in. Run `login`, `restore-session`, or `register-device` (see `help`).\n' + +export type KeeperCliParserOptions = { + prog?: string + description?: string + epilog?: string +} + +/** Self-contained CLI parser. Register commands, then `parse()` dispatches a line. */ +export class KeeperCliParser { + private readonly prog: string + private readonly description: string + private readonly epilog?: string + private readonly commands = new Map() + private readonly aliases = new Map() + + constructor(options: KeeperCliParserOptions = {}) { + this.prog = options.prog ?? 'keeper' + this.description = options.description ?? '' + this.epilog = options.epilog + } + + addCommand(def: CliCommandDefinition): this { + const key = def.name.toLowerCase() + this.commands.set(key, def) + if (def.aliases) { + for (const alias of def.aliases) { + this.aliases.set(alias.toLowerCase(), key) + } + } + return this + } + + addCommands(defs: Iterable): this { + for (const def of defs) this.addCommand(def) + return this + } + + list(): CliCommandDefinition[] { + return [...this.commands.values()].sort((a, b) => { + const oa = a.order ?? 500 + const ob = b.order ?? 500 + if (oa !== ob) return oa - ob + return a.name.localeCompare(b.name) + }) + } + + listNames(): string[] { + return this.list().map((c) => c.name) + } + + resolve(name: string): CliCommandDefinition | undefined { + const key = name.toLowerCase() + if (this.commands.has(key)) return this.commands.get(key) + const target = this.aliases.get(key) + return target ? this.commands.get(target) : undefined + } + + formatHelp(host?: KeeperCliHost): string { + const loggedIn = host?.getVault().isLoggedIn ?? true + const commands = loggedIn + ? this.list() + : this.list().filter((c) => AUTH_CLI_COMMAND_NAMES.has(c.name)) + const header = this.description ? `${this.prog} — ${this.description}\n\n` : '' + const body = loggedIn + ? formatAllCommandsSummary(commands) + : formatAllCommandsSummary(commands, { + header: 'Not logged in — sign-in commands:\n\n', + footer: + '\nRun `login`, `restore-session`, or `register-device` to open the vault.\n' + + 'After login, run `help` again for vault commands (get, ls, cd, …).\n', + }) + const footer = this.epilog ? `\n${this.epilog}\n` : '' + return header + body + footer + } + + formatCommandHelp(name: string): string | null { + const def = this.resolve(name) + return def ? formatDetailedHelpForCommand(def) : null + } + + formatCommandSummary(name: string): string | null { + const def = this.resolve(name) + return def ? formatShortCommandSummary(def) : null + } + + async parse(line: string | readonly string[], host: KeeperCliHost): Promise { + const { tokens, raw } = normalizeInput(line) + if (tokens.length === 0) { + return ok(this.formatHelp(host)) + } + + const first = tokens[0] + const rest = tokens.slice(1) + + if (isHelpToken(first)) { + const sub = rest[0] + if (!sub) return ok(this.formatHelp(host)) + if (!host.getVault().isLoggedIn && !isAuthCliCommand(sub)) { + return err(NOT_LOGGED_IN_ERR) + } + const page = this.formatCommandHelp(sub) + if (page) return ok(page) + return err(`${this.prog}: unknown command: ${sub}\nTry: ${this.prog} --help\n`) + } + + const def = this.resolve(first) + if (!def) { + return err(`${this.prog}: unknown command: ${first}\nTry: ${this.prog} --help\n`) + } + + if (!host.getVault().isLoggedIn && !isAuthCliCommand(def.name)) { + return err(NOT_LOGGED_IN_ERR) + } + + let parsed: ParsedCli + if (def.name === 'restore-session') { + const json = extractFromJsonFlagValue(raw, 'from-json', RESTORE_SESSION_TRAILING_OPTS) + parsed = parseCliArgs(rest) + if (json) parsed.opts.set('from-json', json) + } else { + parsed = parseCliArgs(rest) + } + + if (wantsCliHelp(parsed)) { + return ok(formatDetailedHelpForCommand(def)) + } + return def.run(host, parsed) + } +} + +/** Parser pre-loaded with the SDK's built-in commands. */ +export function createKeeperCliParser(options: KeeperCliParserOptions = {}): KeeperCliParser { + const parser = new KeeperCliParser(options) + void loadBuiltinsInto(parser) + return parser +} + +function loadBuiltinsInto(parser: KeeperCliParser): void { + parser.addCommands(BUILTIN_CLI_COMMANDS) +} + +function normalizeInput(line: string | readonly string[]): { tokens: string[]; raw: string } { + if (typeof line === 'string') { + const trimmed = line.trim() + return { tokens: trimmed ? tokenizeArguments(trimmed) : [], raw: trimmed } + } + const tokens = [...line].filter((t) => t.length > 0) + return { tokens, raw: tokens.join(' ') } +} + +function isHelpToken(token: string): boolean { + const t = token.toLowerCase() + return t === '--help' || t === '-h' || t === 'help' +} + +function ok(out: string): CliResult { + return { code: 0, out, err: '' } +} + +function err(message: string): CliResult { + return { code: 1, out: '', err: message } +} diff --git a/KeeperSdk/src/cli/prompt.ts b/KeeperSdk/src/cli/prompt.ts new file mode 100644 index 00000000..0ac8ea99 --- /dev/null +++ b/KeeperSdk/src/cli/prompt.ts @@ -0,0 +1,12 @@ +import type { KeeperCliHost } from './types' + +const NOT_LOGGED_IN_PROMPT = 'Not logged in> ' +const PROMPT_MAX_LEN = 40 + +export function getKeeperCliPromptPrefix(host: KeeperCliHost): string { + const v = host.getVault() + if (!v.isLoggedIn) return NOT_LOGGED_IN_PROMPT + const name = v.getWorkingFolderDisplayName?.() ?? 'My Vault' + const label = name.length > PROMPT_MAX_LEN ? `...${name.slice(-37)}` : name + return `${label}> ` +} diff --git a/KeeperSdk/src/cli/registry.ts b/KeeperSdk/src/cli/registry.ts new file mode 100644 index 00000000..4e541bac --- /dev/null +++ b/KeeperSdk/src/cli/registry.ts @@ -0,0 +1,55 @@ +import type { CliCommandDefinition } from './types' + +const commands = new Map() +const aliases = new Map() + +function normalizeName(name: string): string { + return name.toLowerCase() +} + +export function registerCliCommand(def: CliCommandDefinition): void { + const key = normalizeName(def.name) + commands.set(key, def) + if (def.aliases) { + for (const alias of def.aliases) { + aliases.set(normalizeName(alias), key) + } + } +} + +export function registerCliAlias(alias: string, commandName: string): void { + aliases.set(normalizeName(alias), normalizeName(commandName)) +} + +export function resolveCliCommandName(name: string): string | undefined { + const key = normalizeName(name) + if (commands.has(key)) return key + return aliases.get(key) +} + +export function getCliCommand(name: string): CliCommandDefinition | undefined { + const key = resolveCliCommandName(name) + return key ? commands.get(key) : undefined +} + +export function listCliCommands(): CliCommandDefinition[] { + return [...commands.values()].sort((a, b) => { + const oa = a.order ?? 500 + const ob = b.order ?? 500 + if (oa !== ob) return oa - ob + return a.name.localeCompare(b.name) + }) +} + +export function listCliCommandNames(): readonly string[] { + return listCliCommands().map((c) => c.name) +} + +export function listDocumentedCommands(): readonly string[] { + return listCliCommandNames() +} + +export function clearCliRegistry(): void { + commands.clear() + aliases.clear() +} diff --git a/KeeperSdk/src/cli/table.ts b/KeeperSdk/src/cli/table.ts new file mode 100644 index 00000000..499f206c --- /dev/null +++ b/KeeperSdk/src/cli/table.ts @@ -0,0 +1,8 @@ +/** Fixed-width column formatter. Last column is left unpadded. */ +export function formatTable(headers: string[], rows: string[][]): string { + if (rows.length === 0) return '' + const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length))) + const fmt = (cells: string[]): string => + cells.map((s, i) => (i === cells.length - 1 ? s : (s ?? '').padEnd(widths[i]))).join(' ') + return [fmt(headers), ...rows.map(fmt)].join('\n') + '\n' +} diff --git a/KeeperSdk/src/cli/types.ts b/KeeperSdk/src/cli/types.ts new file mode 100644 index 00000000..eb9f76a5 --- /dev/null +++ b/KeeperSdk/src/cli/types.ts @@ -0,0 +1,99 @@ +import type { DRecord, DSharedFolder, SyncResult } from '@keeper-security/keeperapi' +import type { SessionRestoreInput } from '../auth/sessionRestore' +import type { ChangeDirectoryResult } from '../folders/changeDirectory' +import type { FolderTreeBuildOptions } from '../folders/folderTree' +import type { GetFolderOptions, GetFolderResult } from '../folders/getFolder' +import type { ListFolderOptions, ListFolderResult } from '../folders/listFolder' +import type { MkdirOptions } from '../folders/addFolder' +import type { RenameFolderResult } from '../folders/updateFolder' +import type { DeleteFolderResult } from '../folders/deleteFolder' +import type { ListSharedFolderRow, ListSharedFoldersOptions } from '../sharedFolders/listSharedFolders' +import type { ListTeamRow, ListTeamsOptions } from '../teams/listTeams' +import type { TeamView } from '../teams/viewTeam' +import type { ListUserRow, ListUsersOptions } from '../users/userTypes' +import type { UserView } from '../users/userTypes' +import type { RecordShareInfo } from '../sharing/Sharing' +import type { VaultSummary } from '../vault/KeeperVault' + +export type CliResult = { + code: number + out: string + err: string + /** Set when the host UI must prompt for a masked password (never on the CLI line). */ + needPassword?: boolean + loginUsername?: string +} + +export type ParsedCli = { + positional: string[] + opts: Map +} + +/** + * Vault surface for CLI handlers. Methods beyond session/sync/records are optional; + * commands call `ensureCapability` so thin hosts fail with a clear message. + */ +export type KeeperCliVault = { + readonly isLoggedIn: boolean + login(username: string, password: string): Promise + loginWithSessionToken(username: string, sessionToken: string): Promise + logout(): Promise + sync(): Promise + getRecords(): DRecord[] + getSharedFolders(): DSharedFolder[] + registerDevice(deviceToken: string, privateKey: string, options?: { username?: string }): Promise + restoreSession(input: SessionRestoreInput): Promise + getSummary?: () => VaultSummary + findRecord?: (uidOrTitle: string) => DRecord | undefined + findRecords?: (criteria: string) => DRecord[] + getRecordShareInfo?: (recordUid: string) => Promise + listSharedFolders?: (options?: ListSharedFoldersOptions) => ListSharedFolderRow[] + listFolder?: (options?: ListFolderOptions) => Promise + tree?: (options?: FolderTreeBuildOptions) => Promise + changeDirectory?: (path: string) => Promise + getCurrentFolderUid?: () => string | null + getWorkingFolderDisplayName?: () => string + getFolder?: (uidOrName: string, options?: GetFolderOptions) => Promise + mkdir?: (path: string, options?: MkdirOptions) => Promise<{ folderUid: string; success: boolean; message?: string }> + renameFolder?: (folderPath: string, newName: string) => Promise + rmdir?: (patterns: string[], options?: { force?: boolean }) => Promise + listTeams?: (options?: ListTeamsOptions) => Promise + viewTeam?: (identifier: string) => Promise + listUsers?: (options?: ListUsersOptions) => Promise + viewUser?: (identifier: string) => Promise +} + +/** Host adapter (browser shell, Node script, tests). `readTextFile` is optional. */ +export type KeeperCliHost = { + getVault(): KeeperCliVault + envString(name: string): string | undefined + formatError(context: string, err: unknown): string + readTextFile?: (path: string) => Promise + getAccountUsername?: () => Promise +} + +export type CliHelpDoc = { + title: string + synopsis?: string + description?: string + arguments?: string + options?: string + environment?: string + examples?: string + seeAlso?: string + note?: string +} + +export type CliCommandDefinition = { + name: string + order?: number + description: string + usage: string + aliases?: readonly string[] + subcommands?: readonly string[] + flagOptions?: readonly string[] + /** When set, options outside this set are rejected (`--help` / `-h` always allowed). */ + allowedOptions?: ReadonlySet + help: CliHelpDoc + run: (host: KeeperCliHost, parsed: ParsedCli) => Promise +} diff --git a/KeeperSdk/src/cli/utils.ts b/KeeperSdk/src/cli/utils.ts new file mode 100644 index 00000000..5db5dae4 --- /dev/null +++ b/KeeperSdk/src/cli/utils.ts @@ -0,0 +1,18 @@ +export function utf8ToBase64Url(s: string): string { + const bytes = new TextEncoder().encode(s) + let b64: string + if (typeof Buffer !== 'undefined') { + b64 = Buffer.from(bytes).toString('base64') + } else { + let bin = '' + for (let i = 0; i < bytes.length; i++) { + bin += String.fromCharCode(bytes[i]!) + } + b64 = globalThis.btoa(bin) + } + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +export function recordUid(rec: { uid?: string }): string { + return rec.uid || '(unknown uid)' +} diff --git a/KeeperSdk/src/cli/vaultSurface.ts b/KeeperSdk/src/cli/vaultSurface.ts new file mode 100644 index 00000000..2c7e65ba --- /dev/null +++ b/KeeperSdk/src/cli/vaultSurface.ts @@ -0,0 +1,18 @@ +/** Shared footer for vault-related command help (SDK APIs not yet exposed as CLI). */ +export const KEEPER_VAULT_SURFACE = ` +KeeperVault (JavaScript SDK) — operations available in code (not all exposed as CLI yet): + + Session: login, loginWithSessionToken, logout, resumeSession, sync, disconnect, registerDevice + Records: getRecords, findRecord, findRecords, getRecordByUid, getRecordsByType, + addRecord, updateRecord, deleteRecord, moveRecord, getRecordHistory, + printRecords + Sharing: shareRecord, removeRecordShare, getRecordShareInfo + Folders: listFolder, changeDirectory, getFolder, mkdir, addFolder, updateFolder, + renameFolder, deleteFolder, rmdir, tree, getCurrentFolderUid + Shared folders: getSharedFolders, listSharedFolders, shareFolder, … + Teams / metadata: getTeams, getRecordMetadata, getSummary, … + +Utilities exported from @keeper-security/keeper-sdk-javascript include searchRecords, +formatRecord, getRecordTitle, getRecordPassword, getRecordLogin, shareRecord, … +See the SDK package for full APIs. +`.trim() diff --git a/KeeperSdk/src/folders/folderTree.ts b/KeeperSdk/src/folders/folderTree.ts index 03f1dfd2..be047709 100644 --- a/KeeperSdk/src/folders/folderTree.ts +++ b/KeeperSdk/src/folders/folderTree.ts @@ -20,6 +20,12 @@ enum TreeItemKind { Folder = 'folder', } +const TREE_TAG = { + folder: '[folder]', + sharedFolder: '[shared folder]', + record: '[record]', +} as const + export type FolderTreeBuildOptions = { folderPath?: string | null verbose?: boolean @@ -130,6 +136,26 @@ async function collectSharedFolderPermissions( return rows.map((row) => ({ display: row.display })) } +function folderTreeTag( + userFolder: DUserFolder | undefined, + sharedFolder: DSharedFolder | undefined, + _sharedFolderFolder: DSharedFolderFolder | undefined +): string { + if (sharedFolder) return TREE_TAG.sharedFolder + if (userFolder) return TREE_TAG.folder + return TREE_TAG.folder +} + +function formatTreeNodeName(baseName: string, tag: string, verbose: boolean, uid?: string): string { + const name = verbose && uid ? `${baseName} (${uid})` : baseName + return `${name} ${tag}` +} + +function formatTreeRecordName(title: string, verbose: boolean, recordUid?: string): string { + const name = verbose && recordUid ? `${title} (${recordUid})` : title + return `${name} ${TREE_TAG.record}` +} + type BuildOpts = Required> & { promotedRootSharedUids?: Set accountUidEmailMap: Map @@ -152,13 +178,12 @@ async function buildFolderSubtree( else if (sharedFolder) baseName = sharedFolderName(sharedFolder) else baseName = sharedFolderFolderName(sharedFolderFolder!) - let displayName = baseName - if (opts.verbose) { - displayName = `${baseName} (${folderUid})` - } - if (sharedFolder) { - displayName += ' [Shared]' - } + let displayName = formatTreeNodeName( + baseName, + folderTreeTag(userFolder, sharedFolder, sharedFolderFolder), + opts.verbose, + folderUid + ) const node: FolderTreeNode = { displayName, children: [] } @@ -188,8 +213,7 @@ async function buildFolderSubtree( node.records = records.map((recordRow) => { const record = storage.getByUid(VaultObjectKind.Record, recordRow.uid) const title = record ? getRecordTitle(record) : recordRow.name - const display = opts.verbose && record ? `${title} (${recordRow.uid}) [Record]` : `${title} [Record]` - return { display } + return { display: formatTreeRecordName(title, opts.verbose, recordRow.uid) } }) } @@ -212,8 +236,7 @@ async function buildVaultRootTree(storage: InMemoryStorage, opts: BuildOpts): Pr node.records = listed.records.map((recordRow) => { const record = storage.getByUid(VaultObjectKind.Record, recordRow.uid) const title = record ? getRecordTitle(record) : recordRow.name - const display = opts.verbose && record ? `${title} (${recordRow.uid}) [Record]` : `${title} [Record]` - return { display } + return { display: formatTreeRecordName(title, opts.verbose, recordRow.uid) } }) } return node diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index b5f27501..6811dc28 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -1,12 +1,13 @@ +import { connectSdkPlatform } from './platform' +import { nodeSdkPlatform } from './platform/node/platform' + +connectSdkPlatform(nodeSdkPlatform) + +export * from './api' + +/** Node.js Commander / CLI helpers (readline, ~/.keeper config). */ export { ConsoleAuthUI } from './auth/ConsoleAuthUI' -export { SessionManager, FileConfigLoader } from './auth/SessionManager' -export type { - KeeperJsonConfig, - ConfigLoader, - ConfigurationUser, - ConfigurationServerConfig, - ConfigurationDeviceConfig, -} from './auth/SessionManager' +export { FileConfigLoader } from './auth/node/FileConfigLoader' export { login, cleanup, prompt, suppressLogs, loadKeeperConfig, resolveServer } from './auth/ConsoleLogin' export { InMemoryStorage } from './storage/InMemoryStorage' @@ -189,6 +190,7 @@ export { export type { ListTeamsOptions, ListTeamRow, + ListTeamSort, TeamColumnInput, FormattedTeamsTable, FormatTeamsTableOptions, diff --git a/KeeperSdk/src/platform/browser/platform.ts b/KeeperSdk/src/platform/browser/platform.ts new file mode 100644 index 00000000..95938970 --- /dev/null +++ b/KeeperSdk/src/platform/browser/platform.ts @@ -0,0 +1,76 @@ +import type { AuthUI3 } from '@keeper-security/keeperapi' +import { UnavailableAuthUI } from '../../auth/UnavailableAuthUI' +import { KeeperSdkError, ResultCodes } from '../../utils' +import type { ConfigLoader } from '../../auth/config' +import * as asmCrypto from 'asmcrypto.js' +import type { SdkPlatform, SdkReadline } from '../types' + +type HmacCtor = new (key: Uint8Array) => { + process(data: Uint8Array): void + finish(): void + result: Uint8Array +} + +const asm = asmCrypto as typeof asmCrypto & { + HmacSha1: HmacCtor + HmacSha256: HmacCtor + HmacSha512: HmacCtor +} + +const HMAC_IMPL: Record<'sha1' | 'sha256' | 'sha512', HmacCtor> = { + sha1: asm.HmacSha1, + sha256: asm.HmacSha256, + sha512: asm.HmacSha512, +} + +const BROWSER_READLINE_MSG = + 'Interactive readline is not available in the browser. Use keeper-shell password transport or a custom authUI.' + +const BROWSER_FILE_CONFIG_MSG = + 'File-based Keeper config (~/.keeper) is not available in the browser. Pass an in-memory ConfigLoader to SessionManager or KeeperVault.sessionStorage.' + +class BrowserReadline implements SdkReadline { + question(_prompt: string): Promise { + return Promise.reject(new KeeperSdkError(BROWSER_READLINE_MSG, ResultCodes.USER_CANCELLED)) + } + close(): void { + /* noop */ + } +} + +export const browserSdkPlatform: SdkPlatform = { + runtime: 'browser', + + delay(ms: number): Promise { + return new Promise((resolve) => globalThis.setTimeout(resolve, ms)) + }, + + createReadline(): SdkReadline { + return new BrowserReadline() + }, + + hmac(algorithm, key, data) { + const Ctor = HMAC_IMPL[algorithm] + if (!Ctor) { + throw new KeeperSdkError(`Unsupported HMAC algorithm: ${algorithm}`, ResultCodes.UNSUPPORTED_2FA_CHANNEL) + } + const h = new Ctor(key) + h.process(data) + h.finish() + return h.result + }, + + createFileConfigLoader(): ConfigLoader { + throw new KeeperSdkError(BROWSER_FILE_CONFIG_MSG, ResultCodes.NOT_LOGGED_IN) + }, + + createAuthUI(useConsoleAuth: boolean): AuthUI3 { + if (useConsoleAuth) { + throw new KeeperSdkError( + 'ConsoleAuthUI (readline) is not available in the browser. Set useConsoleAuth: false and provide authUI, or use keeper-shell.', + ResultCodes.USER_CANCELLED + ) + } + return new UnavailableAuthUI() + }, +} diff --git a/KeeperSdk/src/platform/index.ts b/KeeperSdk/src/platform/index.ts new file mode 100644 index 00000000..32bac290 --- /dev/null +++ b/KeeperSdk/src/platform/index.ts @@ -0,0 +1,20 @@ +import type { SdkPlatform } from './types' + +let active: SdkPlatform | undefined + +export type { SdkPlatform, SdkReadline, SdkRuntime } from './types' + +export function connectSdkPlatform(platform: SdkPlatform): void { + active = platform +} + +export function getSdkPlatform(): SdkPlatform { + if (!active) { + throw new Error('Keeper SDK platform is not initialized. Import @keeper-security/keeper-sdk-javascript or /browser entry first.') + } + return active +} + +export function isSdkPlatformConnected(): boolean { + return active !== undefined +} diff --git a/KeeperSdk/src/platform/node/platform.ts b/KeeperSdk/src/platform/node/platform.ts new file mode 100644 index 00000000..9a3a9b60 --- /dev/null +++ b/KeeperSdk/src/platform/node/platform.ts @@ -0,0 +1,42 @@ +import { createHmac } from 'crypto' +import readline from 'readline/promises' +import type { AuthUI3 } from '@keeper-security/keeperapi' +import { ConsoleAuthUI } from '../../auth/ConsoleAuthUI' +import { UnavailableAuthUI } from '../../auth/UnavailableAuthUI' +import { FileConfigLoader } from '../../auth/node/FileConfigLoader' +import type { ConfigLoader } from '../../auth/config' +import type { SdkPlatform, SdkReadline } from '../types' + +function nodeReadline(input?: unknown, output?: unknown): SdkReadline { + const rl = readline.createInterface({ + input: (input ?? process.stdin) as NodeJS.ReadableStream, + output: (output ?? process.stdout) as NodeJS.WritableStream, + }) + return { + question: (prompt) => rl.question(prompt), + close: () => rl.close(), + } +} + +export const nodeSdkPlatform: SdkPlatform = { + runtime: 'node', + + delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) + }, + + createReadline: nodeReadline, + + hmac(algorithm, key, data) { + const algo = algorithm.toLowerCase() + return new Uint8Array(createHmac(algo, Buffer.from(key)).update(data).digest()) + }, + + createFileConfigLoader(configDir?: string): ConfigLoader { + return new FileConfigLoader(configDir) + }, + + createAuthUI(useConsoleAuth: boolean): AuthUI3 { + return useConsoleAuth ? new ConsoleAuthUI() : new UnavailableAuthUI() + }, +} diff --git a/KeeperSdk/src/platform/types.ts b/KeeperSdk/src/platform/types.ts new file mode 100644 index 00000000..7816e006 --- /dev/null +++ b/KeeperSdk/src/platform/types.ts @@ -0,0 +1,25 @@ +import type { AuthUI3 } from '@keeper-security/keeperapi' +import type { ConfigLoader } from '../auth/config' + +export type SdkRuntime = 'node' | 'browser' + +export interface SdkReadline { + question(prompt: string): Promise + close(): void +} + +/** Small platform surface for KeeperSdk (auth UI, config, TOTP, timers). */ +export interface SdkPlatform { + readonly runtime: SdkRuntime + + delay(ms: number): Promise + + createReadline(input?: unknown, output?: unknown): SdkReadline + + /** HMAC digest (e.g. SHA-1 for TOTP). */ + hmac(algorithm: 'sha1' | 'sha256' | 'sha512', key: Uint8Array, data: Uint8Array): Uint8Array + + createFileConfigLoader(configDir?: string): ConfigLoader + + createAuthUI(useConsoleAuth: boolean): AuthUI3 +} diff --git a/KeeperSdk/src/records/RecordUtils.ts b/KeeperSdk/src/records/RecordUtils.ts index 74630faa..699f5f8c 100644 --- a/KeeperSdk/src/records/RecordUtils.ts +++ b/KeeperSdk/src/records/RecordUtils.ts @@ -209,6 +209,22 @@ export function getRecordUrl(record: DRecord): string | undefined { return getRecordSummary(record).url } +/** Commander-style list description (login @ url for most record types). */ +export function getRecordDescription(record: DRecord): string { + if (record.version === 6) return 'PAM Configuration' + + const summary = getRecordSummary(record) + const parts: string[] = [] + if (summary.login) parts.push(summary.login) + if (summary.url) parts.push(summary.url) + return parts.length > 0 ? parts.join(' @ ') : '' +} + +/** Commander list column: Nested Share / Keeper Drive vs classic vault records. */ +export function getRecordCategory(record: DRecord): 'Classic' | 'Nested' { + return record.isKeeperDriveData ? 'Nested' : 'Classic' +} + const wordCache = new WeakMap() export function searchRecords(records: DRecord[], criteria: string): DRecord[] { @@ -250,7 +266,63 @@ function collectRecordWords(record: DRecord): string[] { return words } -export function formatRecord(record: DRecord, showDetails = false): string { +export type FormatRecordOptions = { + showDetails?: boolean + unmask?: boolean +} + +function resolveFormatRecordOptions(showDetailsOrOptions?: boolean | FormatRecordOptions): { + showDetails: boolean + unmask: boolean +} { + if (typeof showDetailsOrOptions === 'boolean') { + return { showDetails: showDetailsOrOptions, unmask: false } + } + return { + showDetails: showDetailsOrOptions?.showDetails ?? false, + unmask: showDetailsOrOptions?.unmask ?? false, + } +} + +function formatFieldValue(field: RecordField, unmask: boolean): string { + const isTotp = TOTP_FIELD_TYPES.has(field.type) + const isSensitive = field.type === FieldType.Password || isTotp || field.privacyScreen === true + if (!isSensitive || unmask) { + return field.value.map((v) => (typeof v === 'string' ? v : JSON.stringify(v))).join(', ') + } + return MASKED_VALUE +} + +export function formatRecordFields(record: DRecord, unmask: boolean): { name: string; value: unknown }[] { + const summary = getRecordSummary(record) + const fields: { name: string; value: unknown }[] = [ + { name: 'title', value: getRecordTitle(record) }, + { name: 'record_uid', value: record.uid }, + { name: 'version', value: record.version }, + { name: 'record_type', value: getRecordType(record) }, + ] + if (summary.login) fields.push({ name: 'login', value: summary.login }) + if (summary.password) { + fields.push({ + name: 'password', + value: unmask ? summary.password : MASKED_VALUE, + }) + } + if (summary.url) fields.push({ name: 'login_url', value: summary.url }) + for (const field of summary.fields) { + if (field.type === FieldType.Login || field.type === FieldType.Url) continue + const label = TOTP_FIELD_TYPES.has(field.type) + ? 'TOTP URL' + : (field.label || field.type).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + fields.push({ name: label, value: formatFieldValue(field, unmask) }) + } + const notes = record.version <= RecordVersion.Legacy ? record.data?.notes : undefined + if (notes) fields.push({ name: 'Notes', value: notes }) + return fields +} + +export function formatRecord(record: DRecord, showDetailsOrOptions?: boolean | FormatRecordOptions): string { + const { showDetails, unmask } = resolveFormatRecordOptions(showDetailsOrOptions) const summary = getRecordSummary(record) const lines: string[] = [ RECORD_SEPARATOR, @@ -261,17 +333,23 @@ export function formatRecord(record: DRecord, showDetails = false): string { if (summary.login) lines.push(`Username: ${summary.login}`) if (summary.url) lines.push(`URL: ${summary.url}`) + if (summary.password) { + lines.push(`Password: ${unmask ? summary.password : MASKED_VALUE}`) + } if (showDetails) { for (const field of summary.fields) { if (field.type === FieldType.Login || field.type === FieldType.Url) continue + if (field.type === FieldType.Password) continue const isTotp = TOTP_FIELD_TYPES.has(field.type) - const isSensitive = field.type === FieldType.Password || isTotp const label = isTotp ? 'TOTP URL' : field.label || field.type - lines.push(`${label}: ${isSensitive ? MASKED_VALUE : field.value.join(', ')}`) + lines.push(`${label}: ${formatFieldValue(field, unmask)}`) } const totpUrl = getRecordTotpUrl(record) + if (unmask && totpUrl) { + lines.push(`TOTP URL: ${totpUrl}`) + } const code = totpUrl ? getTotpCode(totpUrl) : null if (code) { lines.push(`Two Factor Code: ${code.code} valid for ${code.secondsRemaining} sec`) diff --git a/KeeperSdk/src/records/Totp.ts b/KeeperSdk/src/records/Totp.ts index 1ba67d53..92980757 100644 --- a/KeeperSdk/src/records/Totp.ts +++ b/KeeperSdk/src/records/Totp.ts @@ -1,4 +1,4 @@ -import { createHmac } from 'crypto' +import { getSdkPlatform } from '../platform' export type TotpAlgorithm = 'SHA1' | 'SHA256' | 'SHA512' @@ -22,10 +22,7 @@ const DEFAULT_ALGORITHM: TotpAlgorithm = 'SHA1' const UINT32_MAX = 0x100000000 function decodeBase32(input: string): Uint8Array { - const noWhitespace = input.replace(/\s+/g, '') - let endIndex = noWhitespace.length - while (endIndex > 0 && noWhitespace.charCodeAt(endIndex - 1) === 0x3d) endIndex-- - const cleaned = noWhitespace.slice(0, endIndex).toUpperCase() + const cleaned = input.replace(/=+$/g, '').replace(/\s+/g, '').toUpperCase() const out: number[] = [] let buffer = 0 let bits = 0 @@ -76,10 +73,11 @@ export function parseTotpUrl(url: string): TotpParams | null { } } -function counterToBuffer(counter: number): Buffer { - const buf = Buffer.alloc(8) - buf.writeUInt32BE(Math.floor(counter / UINT32_MAX), 0) - buf.writeUInt32BE(counter % UINT32_MAX, 4) +function counterToBuffer(counter: number): Uint8Array { + const buf = new Uint8Array(8) + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) + view.setUint32(0, Math.floor(counter / UINT32_MAX), false) + view.setUint32(4, counter >>> 0, false) return buf } @@ -101,9 +99,8 @@ export function getTotpCode(urlOrParams: string | TotpParams, now: number = Date const counter = Math.floor(seconds / params.period) const secondsRemaining = params.period - (seconds % params.period) - const digest = createHmac(params.algorithm.toLowerCase(), Buffer.from(key)) - .update(counterToBuffer(counter)) - .digest() + const algo = params.algorithm.toLowerCase() as 'sha1' | 'sha256' | 'sha512' + const digest = getSdkPlatform().hmac(algo, key, counterToBuffer(counter)) if (digest.length === 0) return null const offset = digest[digest.length - 1] & 0x0f diff --git a/KeeperSdk/src/records/listRecordsTable.ts b/KeeperSdk/src/records/listRecordsTable.ts new file mode 100644 index 00000000..17977855 --- /dev/null +++ b/KeeperSdk/src/records/listRecordsTable.ts @@ -0,0 +1,95 @@ +import type { DRecord } from '@keeper-security/keeperapi' +import { + getRecordCategory, + getRecordDescription, + getRecordTitle, + getRecordType, +} from './RecordUtils' + +const DEFAULT_COLUMN_WIDTH = 40 +const MIN_TRUNCATE_PREFIX = 3 + +export type FormattedRecordsListTable = { + headers: string[] + rows: string[][] +} + +function truncateText(text: string, maxLength: number | null): string { + if (!text) return '' + if (maxLength == null || text.length <= maxLength) return text + if (maxLength <= MIN_TRUNCATE_PREFIX) return text.slice(0, maxLength) + return `${text.slice(0, maxLength - MIN_TRUNCATE_PREFIX)}...` +} + +function compareByTitle(recordA: DRecord, recordB: DRecord): number { + const titleA = getRecordTitle(recordA) + const titleB = getRecordTitle(recordB) + return titleA.localeCompare(titleB, undefined, { sensitivity: 'base' }) +} + +export function formatRecordsListTable( + records: DRecord[], + options: { verbose?: boolean; columnWidth?: number } = {} +): FormattedRecordsListTable { + const { verbose = false, columnWidth = DEFAULT_COLUMN_WIDTH } = options + const maxWidth = verbose ? null : columnWidth + const sorted = [...records].sort(compareByTitle) + const headers = [ + '#', + 'Record uid', + 'Type', + 'Title', + 'Description', + 'Shared', + 'Record category', + ] + const rows = sorted.map((record, index) => { + const uid = truncateText(record.uid || '(unknown uid)', maxWidth) + const type = truncateText(getRecordType(record), maxWidth) + const title = truncateText(getRecordTitle(record), maxWidth) + const description = truncateText(getRecordDescription(record), maxWidth) + const shared = record.shared ? 'True' : 'False' + const category = getRecordCategory(record) + return [String(index + 1), uid, type, title, description, shared, category] + }) + return { headers, rows } +} + +export function renderRecordsListAsciiTable( + table: FormattedRecordsListTable, + options: { minColWidth?: number } = {} +): string { + const { minColWidth = 2 } = options + const { headers, rows } = table + const columnCount = headers.length + const columnWidths: number[] = new Array(columnCount).fill(0) + for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) { + columnWidths[columnIndex] = Math.max(headers[columnIndex].length, minColWidth) + } + for (const row of rows) { + for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) { + const cell = row[columnIndex] || '' + columnWidths[columnIndex] = Math.max(columnWidths[columnIndex], cell.length, minColWidth) + } + } + const padCell = (cell: string, columnIndex: number) => + cell + ' '.repeat(columnWidths[columnIndex] - cell.length) + const formatRow = (cells: string[]) => cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') + const ruleRow = Array.from({ length: columnCount }, (_unused, columnIndex) => + '-'.repeat(columnWidths[columnIndex]) + ) + .map((dashes, columnIndex) => padCell(dashes, columnIndex)) + .join(' ') + const lines: string[] = [formatRow(headers), ruleRow] + for (const row of rows) { + lines.push(formatRow(row)) + } + return lines.join('\n') +} + +export function renderRecordsListTable( + records: DRecord[], + options: { verbose?: boolean; columnWidth?: number } = {} +): string { + return renderRecordsListAsciiTable(formatRecordsListTable(records, options)) +} diff --git a/KeeperSdk/src/storage/InMemoryStorage.ts b/KeeperSdk/src/storage/InMemoryStorage.ts index 89424b97..1ddbe973 100644 --- a/KeeperSdk/src/storage/InMemoryStorage.ts +++ b/KeeperSdk/src/storage/InMemoryStorage.ts @@ -37,6 +37,9 @@ export class InMemoryStorage implements VaultStorage { public async put(item: VaultStorageData): Promise { const kind = item.kind + if (!kind) { + throw new Error('VaultStorageData missing kind') + } if (!this.store.has(kind)) { this.store.set(kind, new Map()) } diff --git a/KeeperSdk/src/teams/listTeams.ts b/KeeperSdk/src/teams/listTeams.ts index 391238aa..3d055cc5 100644 --- a/KeeperSdk/src/teams/listTeams.ts +++ b/KeeperSdk/src/teams/listTeams.ts @@ -12,6 +12,7 @@ import { type EnterpriseUser, type GetEnterpriseDataResponse, } from './enterpriseData' +import { fetchShareObjects, fetchTeamMemberEmails, resolvePrimaryEnterpriseId } from './shareTeams' export enum TeamColumn { Restricts = 'restricts', @@ -46,14 +47,27 @@ const HEADER_BY_COLUMN: Record = { [TeamColumn.Roles]: 'Roles', } +export type ListTeamSort = 'company' | 'team_uid' | 'name' + export type ListTeamsOptions = { pattern?: string | null + /** Show teams from all enterprises in contacts (Commander --all). Default: primary org only. */ + all?: boolean + /** Include Member column from enterprise cache (Commander -v). */ + verbose?: boolean + /** Fetch members via vault/get_team_members when not cached (Commander -vv). */ + veryVerbose?: boolean + sort?: ListTeamSort + /** Enterprise admin detail columns; when set, uses get_enterprise_data instead of share objects. */ columns?: TeamColumnInput[] | typeof ALL_COLUMNS_WILDCARD | string | null } export type ListTeamRow = { team_uid: string name: string + company?: string + enterprise_id?: number + member?: string restricts?: string node?: string user_count?: number @@ -69,6 +83,9 @@ export type FormattedTeamsTable = { export type FormatTeamsTableOptions = { columns?: ListTeamsOptions['columns'] + /** Commander -v / -vv: show Member column. */ + showMember?: boolean + style?: 'commander' | 'enterprise' } type DecorateContext = { @@ -88,6 +105,49 @@ export function formatTeamRestricts(team: EnterpriseTeamRecord): string { } export async function listTeams(auth: Auth, options: ListTeamsOptions = {}): Promise { + if (options.columns != null) { + return listTeamsEnterprise(auth, options) + } + return listTeamsCommander(auth, options) +} + +async function listTeamsCommander(auth: Auth, options: ListTeamsOptions): Promise { + const [shareObjects, primaryEnterpriseId] = await Promise.all([ + fetchShareObjects(auth), + resolvePrimaryEnterpriseId(auth), + ]) + const showAll = options.all === true + const pattern = options.pattern?.trim() || null + const showMembers = options.verbose === true || options.veryVerbose === true + + const rows: ListTeamRow[] = [] + for (const team of shareObjects.teams.values()) { + if (!showAll && primaryEnterpriseId != null && team.enterprise_id !== primaryEnterpriseId) { + continue + } + const company = + team.enterprise_id != null + ? shareObjects.enterprises.get(team.enterprise_id) || '' + : '' + const row: ListTeamRow = { + team_uid: team.team_uid, + name: team.name, + company, + enterprise_id: team.enterprise_id, + } + if (pattern && !rowMatchesPattern(row, pattern)) continue + rows.push(row) + } + + if (showMembers) { + await attachCommanderTeamMembers(auth, rows, options.veryVerbose === true) + } + + sortCommanderRows(rows, options.sort || 'company') + return rows +} + +async function listTeamsEnterprise(auth: Auth, options: ListTeamsOptions): Promise { const columns = resolveColumns(options.columns) const includes = includesForColumns(columns) const wantsDisplayNames = columns.includes(TeamColumn.Node) || columns.includes(TeamColumn.Roles) @@ -131,6 +191,24 @@ export function formatTeamsTable( rows: ListTeamRow[], options: FormatTeamsTableOptions = {} ): FormattedTeamsTable { + const style = options.style ?? (options.columns != null ? 'enterprise' : 'commander') + if (style === 'commander') { + const showMember = options.showMember === true + const headers = ['#', 'Company', 'Team UID', 'Name'] + if (showMember) headers.push('Member') + const outRows = rows.map((row, rowIndex) => { + const cells = [ + String(rowIndex + 1), + row.company ?? '', + row.team_uid, + row.name, + ] + if (showMember) cells.push(row.member ?? '') + return cells + }) + return { headers, rows: outRows } + } + const columns = resolveColumns(options.columns) const headers: string[] = ['#', 'Team UID', 'Name', ...columns.map((column) => HEADER_BY_COLUMN[column])] @@ -229,11 +307,58 @@ function tokenize(text: string): string[] { return text.split(TOKEN_SEPARATOR_PATTERN).filter((token) => token.length > 0) } +async function attachCommanderTeamMembers( + auth: Auth, + rows: ListTeamRow[], + fetchMissing: boolean +): Promise { + const enterpriseData = new EnterpriseDataManager(auth) + const response = await enterpriseData.getData([ + EnterpriseDataInclude.TeamUsers, + EnterpriseDataInclude.Users, + ]) + const teamUsers = buildTeamUserMap(response.team_users || []) + const usernameById = buildUserUsernameMap(response.users || []) + + for (const row of rows) { + const ids = teamUsers.get(row.team_uid) + if (ids && ids.size > 0) { + row.member = resolveSortedNames(ids, usernameById).join('\n') + continue + } + if (!fetchMissing) continue + try { + const emails = await fetchTeamMemberEmails(auth, row.team_uid) + if (emails.length > 0) row.member = emails.join('\n') + } catch { + /* best-effort; leave member blank */ + } + } +} + +function sortCommanderRows(rows: ListTeamRow[], sort: ListTeamSort): void { + if (sort === 'team_uid') { + rows.sort((a, b) => a.team_uid.localeCompare(b.team_uid, undefined, { sensitivity: 'base' })) + return + } + if (sort === 'name') { + rows.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) + return + } + rows.sort((a, b) => { + const companyCmp = (a.company || '').localeCompare(b.company || '', undefined, { sensitivity: 'base' }) + if (companyCmp !== 0) return companyCmp + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + }) +} + function rowMatchesPattern(row: ListTeamRow, pattern: string): boolean { const lowered = pattern.toLowerCase() const tokens: string[] = [] tokens.push(row.team_uid.toLowerCase()) tokens.push(...tokenize(row.name.toLowerCase())) + if (row.company) tokens.push(...tokenize(row.company.toLowerCase())) + if (row.member) tokens.push(...tokenize(row.member.toLowerCase())) if (row.restricts) tokens.push(row.restricts.toLowerCase()) if (row.node) tokens.push(...tokenize(row.node.toLowerCase())) if (row.user_count != null) tokens.push(String(row.user_count)) diff --git a/KeeperSdk/src/teams/shareTeams.ts b/KeeperSdk/src/teams/shareTeams.ts new file mode 100644 index 00000000..0164bb95 --- /dev/null +++ b/KeeperSdk/src/teams/shareTeams.ts @@ -0,0 +1,79 @@ +import { + getShareObjectsMessage, + getTeamMembersMessage, + normal64Bytes, + webSafe64FromBytes, + type Auth, + type Records, +} from '@keeper-security/keeperapi' + +export type ShareTeamEntry = { + team_uid: string + name: string + enterprise_id?: number +} + +export type ShareObjectsSnapshot = { + enterprises: Map + teams: Map +} + +function teamUidFromShareTeam(team: Records.IShareTeam): string | null { + if (!team.teamUid) return null + if (team.teamUid instanceof Uint8Array) return webSafe64FromBytes(team.teamUid) + if (typeof team.teamUid === 'string') return team.teamUid + return null +} + +function addShareTeams(target: Map, list: Records.IShareTeam[] | null | undefined): void { + for (const team of list || []) { + const team_uid = teamUidFromShareTeam(team) + if (!team_uid) continue + target.set(team_uid, { + team_uid, + name: (team.teamname || team_uid).trim() || team_uid, + enterprise_id: team.enterpriseId ?? undefined, + }) + } +} + +/** Teams and enterprise names from vault/get_share_objects (Keeper Commander contacts cache). */ +export async function fetchShareObjects(auth: Auth): Promise { + const response = await auth.executeRest(getShareObjectsMessage({})) + const enterprises = new Map() + for (const entry of response.shareEnterpriseNames || []) { + if (entry.enterpriseId == null) continue + enterprises.set(entry.enterpriseId, (entry.enterprisename || '').trim()) + } + + const teams = new Map() + addShareTeams(teams, response.shareTeams) + addShareTeams(teams, response.shareMCTeams) + return { enterprises, teams } +} + +export async function resolvePrimaryEnterpriseId(auth: Auth): Promise { + let enterpriseId = auth.accountSummary?.license?.enterpriseId ?? undefined + if (enterpriseId != null) return enterpriseId + try { + await auth.loadAccountSummary() + } catch { + return undefined + } + return auth.accountSummary?.license?.enterpriseId ?? undefined +} + +export async function fetchTeamMemberEmails(auth: Auth, teamUid: string): Promise { + const response = await auth.executeRest( + getTeamMembersMessage({ + teamUid: normal64Bytes(teamUid), + }) + ) + const emails: string[] = [] + for (const user of response.enterpriseUser || []) { + const email = (user.email || user.enterpriseUsername || '').trim() + if (email) emails.push(email) + } + emails.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) + return emails +} diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index 742a54d3..2aa9ed8c 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -69,6 +69,10 @@ export enum UserErrorCode { TeamUserRemoveFailed = 'team_user_remove_failed', } +export enum SyncErrorCode { + SyncFailed = 'sync_failed', +} + export const ResultCodes = { INVALID_CREDENTIALS: AuthErrorCode.InvalidCredentials, MISSING_USERNAME: AuthErrorCode.MissingUsername, @@ -116,6 +120,7 @@ export const ResultCodes = { NO_TEAMS_FOR_USER_OP: UserErrorCode.NoTeamsForUserOp, TEAM_USER_ADD_FAILED: UserErrorCode.TeamUserAddFailed, TEAM_USER_REMOVE_FAILED: UserErrorCode.TeamUserRemoveFailed, + SYNC_FAILED: SyncErrorCode.SyncFailed, } as const export const KEEPER_PUBLIC_HOSTS: Record = { diff --git a/KeeperSdk/src/utils/index.ts b/KeeperSdk/src/utils/index.ts index 2875607d..b706cb32 100644 --- a/KeeperSdk/src/utils/index.ts +++ b/KeeperSdk/src/utils/index.ts @@ -18,6 +18,8 @@ export { EMAIL_LIST_SEPARATOR_PATTERN, TOKEN_SEPARATOR_PATTERN, REGEX_ESCAPE_PATTERN, + TRAILING_EQUALS_PATTERN, + WHITESPACE_PATTERN, isValidEmail, escapeRegExp, } from './patterns' diff --git a/KeeperSdk/src/utils/patterns.ts b/KeeperSdk/src/utils/patterns.ts index bc116298..c4e186b0 100644 --- a/KeeperSdk/src/utils/patterns.ts +++ b/KeeperSdk/src/utils/patterns.ts @@ -1,4 +1,4 @@ -export const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+\.[^\s@]+$/ +export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ /** Splits user-entered email lists on whitespace, commas, and semicolons. */ export const EMAIL_LIST_SEPARATOR_PATTERN = /[\s,;]+/ @@ -9,10 +9,14 @@ export const TOKEN_SEPARATOR_PATTERN = /[\s\-_.,;:!?@#$%^&*()[\]{}|\\/<>]+/ /** Characters that must be escaped when embedding user input into a RegExp. */ export const REGEX_ESCAPE_PATTERN = /[.+^${}()|[\]\\]/g -const MAX_EMAIL_LENGTH = 254 +/** Sequence of one or more `=` characters at end of string (Base32 padding). */ +export const TRAILING_EQUALS_PATTERN = /=+$/g + +/** Any whitespace run. */ +export const WHITESPACE_PATTERN = /\s+/g export function isValidEmail(value: string): boolean { - return value.length <= MAX_EMAIL_LENGTH && EMAIL_PATTERN.test(value) + return EMAIL_PATTERN.test(value) } export function escapeRegExp(value: string): string { diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 070b5d78..46425167 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -8,11 +8,16 @@ import { DTeam, DUserFolder, Authentication, + normal64Bytes, } from '@keeper-security/keeperapi' import type { SyncResult, SyncLogFormat, VaultStorage, SessionStorage, AuthUI3 } from '@keeper-security/keeperapi' import { InMemoryStorage } from '../storage/InMemoryStorage' import { SessionManager } from '../auth/SessionManager' -import { ConsoleAuthUI } from '../auth/ConsoleAuthUI' +import { getSdkPlatform } from '../platform' +import { + toSessionParams, + type SessionRestoreInput, +} from '../auth/sessionRestore' import { searchRecords, formatRecord, getRecordTitle, getRecordType } from '../records/RecordUtils' import { addRecord as addRecordOp, @@ -87,7 +92,15 @@ import type { TeamUserResult, FormattedTeamUserTable, } from '../users/userTypes' -import { ConsoleLogger, LogLevel, KeeperSdkError, extractErrorMessage, SdkDefaults, ResultCodes } from '../utils' +import { + ConsoleLogger, + LogLevel, + KeeperSdkError, + extractErrorMessage, + extractResultCode, + SdkDefaults, + ResultCodes, +} from '../utils' import type { ILogger } from '../utils' enum VaultStatus { @@ -144,7 +157,7 @@ export class KeeperVault { this.log = new ConsoleLogger(this.config.logLevel) this.storage = config?.storage || new InMemoryStorage() this.sessionManager = config?.sessionStorage || new SessionManager(this.config.configDir || undefined) - this.authUI = config?.authUI || new ConsoleAuthUI() + this.authUI = config?.authUI ?? getSdkPlatform().createAuthUI(this.config.useConsoleAuth) const authProvider = () => this.getAuthOrThrow() this.folderManager = new FolderManager(this.storage, this.folderSession, authProvider) @@ -247,10 +260,75 @@ export class KeeperVault { this.log.info(`Logged in as ${username} (via session token)`) } + /** + * Persist device token and private key for this vault host in session storage so + * {@link loginWithSessionToken} and {@link resumeSession} work without a prior password login on this machine. + * Values use the same base64 / base64url decoding as {@link SessionManager} (`normal64Bytes`). + */ + public async registerDevice( + deviceToken: string, + privateKey: string, + options?: { username?: string } + ): Promise { + const host = this.config.host + const save = this.sessionManager.createOnDeviceConfig(host) + await save({ + deviceToken: normal64Bytes(deviceToken), + privateKey: normal64Bytes(privateKey), + }) + if (options?.username) { + this.sessionManager.setLastUsername(options.username) + } + this.log.info(`Device credentials stored for host ${host}`) + } + public getSessionToken(): string | undefined { return this.auth?.sessionToken || undefined } + /** + * Resume a session from extension-exported {@link SessionRestoreInput}. + * Verifies the token with a lightweight server call so an expired session + * fails here, not later from `sync()`. + */ + public async restoreSession(input: SessionRestoreInput): Promise { + const params = toSessionParams(input) + await this.sessionManager.saveSessionParameters(params) + this.sessionManager.setLastUsername(params.username) + + this.auth = await this.createAuth() + await this.auth.continueSession() + + if (!this.auth.sessionToken) { + throw new KeeperSdkError( + 'Session restore failed — session token may be expired or invalid.', + ResultCodes.SESSION_TOKEN_EXPIRED + ) + } + + try { + await this.auth.loadAccountSummary() + } catch (err) { + const code = extractResultCode(err) + const msg = extractErrorMessage(err) + this.disconnect() + const isExpired = code === ResultCodes.SESSION_TOKEN_EXPIRED + throw new KeeperSdkError( + isExpired + ? `Session token rejected by server (${code}): ${msg}. Re-export session JSON and try again.` + : `Session restore failed: ${msg}`, + code ?? ResultCodes.SESSION_TOKEN_EXPIRED + ) + } + + this.synced = false + this.log.info(`Session restored for ${params.username}`) + } + + public async getAccountUsername(): Promise { + return this.sessionManager.getLastUsername() + } + public async resumeSession(): Promise { const username = await this.sessionManager.getLastUsername() if (!username) { @@ -299,14 +377,22 @@ export class KeeperVault { public async sync(): Promise { const auth = this.getAuthOrThrow() - const result = await syncDown({ - auth, - storage: this.storage, - logFormat: this.config.logFormat, - }) - - this.synced = true - return result + try{ + const result = await syncDown({ + auth, + storage: this.storage, + logFormat: this.config.logFormat, + }) + if (result.error) { + this.log.error('Sync error:', result.error) + throw new KeeperSdkError(`Sync failed: ${result.error}`, ResultCodes.SYNC_FAILED) + } + this.synced = true + return result + }catch(e) { + this.log.error('Sync failed:', extractErrorMessage(e)) + throw e + } } public async batch(fn: () => Promise): Promise { diff --git a/KeeperSdk/tsconfig.json b/KeeperSdk/tsconfig.json index 74c99318..15eafc2b 100644 --- a/KeeperSdk/tsconfig.json +++ b/KeeperSdk/tsconfig.json @@ -7,7 +7,7 @@ "skipLibCheck": true, "esModuleInterop": true, "declaration": true, - "rootDir": ".", + "rootDir": "src", "outDir": "dist", "types": ["node"] }, diff --git a/examples/sdk_example/README.md b/examples/sdk_example/README.md index 7a7477c3..45455b71 100644 --- a/examples/sdk_example/README.md +++ b/examples/sdk_example/README.md @@ -24,20 +24,24 @@ npm run link-local Examples use `~/.keeper/config.json` for saved credentials and persistent login. If the file is not found, you will be prompted for server, username, and password. -## Available Examples +For restore-session flows, provide a path to session JSON (extension `SessionParams` shape). There is no default path. + + ## Available Examples ### Authentication | Command | Description | |---|---| | `npm run auth:login` | Master password login with retry logic, masked input, and vault sync. Automatically attempts persistent login (via clone code from `~/.keeper/config.json`) before falling back to the password prompt. | -| `npm run auth:session-token` | Login using an existing session token for pre-authenticated workflows. Prompts for username, host, and session token. | +| `npm run auth:session-token` | Login using an existing session token for pre-authenticated workflows. Prompts for username, host, and session token. Requires device token + private key in `~/.keeper/config.json` or use `auth:register-device` first in the same run. | +| `npm run auth:register-device` | Store device token + device private key on a `KeeperVault` (in-memory), optionally then `loginWithSessionToken` + sync. | +| `npm run auth:restore-session` | Restore via SDK `restore-session` CLI dispatch (same path as shellcomponent). Prompts for session JSON path. | ### Records | Command | Description | |---|---| -| `npm run records:list` | List all records in the vault | +| `npm run records:list` | List all records in the vault (password / persistent login via `login()`) | | `npm run records:get` | Get details of a specific record by UID or title | | `npm run records:add` | Add a new typed record to the vault | | `npm run records:update` | Update fields on an existing record | @@ -63,3 +67,17 @@ npm run records:get ``` Most examples will log in automatically using persistent login (if configured) or prompt for credentials. After authentication, follow the interactive prompts. + +**Restore-session** on `records:list` (requires `--from-json`): + +```bash +npm run records:list -- --restore-session --from-json /path/to/session.json [--host keepersecurity.eu] [--no-sync] +``` + +**Shell-parity debug** — same dispatch path as `` (uses `dispatchCliLine` → `restore-session`): + +```bash +npm run records:list:shell-cli -- --from-json /path/to/session.json [--host keepersecurity.eu] +``` + +If Node works but the browser shell fails, suspect host I/O (`readTextFile` / Vite `/@fs`), CORS, or region — not `KeeperVault.restoreSession` itself. diff --git a/examples/sdk_example/package-lock.json b/examples/sdk_example/package-lock.json index 052d934b..22c3a460 100644 --- a/examples/sdk_example/package-lock.json +++ b/examples/sdk_example/package-lock.json @@ -8,11 +8,12 @@ "name": "sdk-example", "version": "1.0.0", "dependencies": { - "@keeper-security/keeperapi": "17.1.0", - "@types/node": "^20.9.1", "ts-node": "^10.7.0", "tsconfig-paths": "^4.2.0", "typescript": "^4.6.3" + }, + "devDependencies": { + "@types/node": "^25.6.2" } }, "node_modules/@cspotcode/source-map-support": { @@ -52,62 +53,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@keeper-security/keeperapi": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-17.1.0.tgz", - "integrity": "sha512-BV1o72g1BXz3Agjw0l6kLlS5SOvWCJ0qD2RcEXPAvMQJcq0O8SLaf2KhIG/dGHPHdNZ5Dod4+cI0sAfnpZeYZw==", - "license": "ISC", - "dependencies": { - "@noble/post-quantum": "^0.5.2", - "asmcrypto.js": "^2.3.2", - "faye-websocket": "^0.11.3", - "form-data": "^4.0.4", - "node-rsa": "^1.0.8" - } - }, - "node_modules/@noble/curves": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", - "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "2.0.1" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/post-quantum": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.5.4.tgz", - "integrity": "sha512-leww0zzIirrvwaYMPI9fj6aRIlA/c6Y0/lifQQ1YOOyHEr0MNH3yYpjXeiVG+tWdPps4XxGclFWX2INPO3Yo5w==", - "license": "MIT", - "dependencies": { - "@noble/curves": "~2.0.0", - "@noble/hashes": "~2.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -133,12 +78,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", + "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", "license": "MIT", + "peer": true, "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.21.0" } }, "node_modules/acorn": { @@ -171,67 +117,12 @@ "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "license": "MIT" }, - "node_modules/asmcrypto.js": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/asmcrypto.js/-/asmcrypto.js-2.3.2.tgz", - "integrity": "sha512-3FgFARf7RupsZETQ1nHnhLUUvpcttcCq1iZCaVAbJZbCZ5VNRrNyvpDyHTOb0KC3llFcsyOT/a99NZcCbeiEsA==", - "license": "MIT" - }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "license": "MIT" }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -241,196 +132,6 @@ "node": ">=0.3.1" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "license": "Apache-2.0", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", - "license": "MIT" - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -449,36 +150,6 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "license": "ISC" }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -488,41 +159,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/node-rsa": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.1.1.tgz", - "integrity": "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==", - "license": "MIT", - "dependencies": { - "asn1": "^0.2.4" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -594,6 +230,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -603,9 +240,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", + "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", "license": "MIT" }, "node_modules/v8-compile-cache-lib": { @@ -614,29 +251,6 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "license": "MIT" }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "license": "Apache-2.0", - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index 93463834..4fe6b103 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -6,7 +6,9 @@ "auth:login": "ts-node src/auth/login.ts", "auth:session-token": "ts-node src/auth/session_token_login.ts", "auth:register-device": "ts-node src/auth/register_device.ts", + "auth:restore-session": "ts-node src/auth/restore_session.ts", "records:list": "ts-node src/records/list_records.ts", + "records:list:shell-cli": "ts-node src/records/list_records_shell_cli.ts", "records:get": "ts-node src/records/get_record.ts", "records:add": "ts-node src/records/add_record.ts", "records:update": "ts-node src/records/update_record.ts", @@ -39,13 +41,15 @@ "users:user-team": "ts-node src/users/team_user.ts", "link-local": "cd ../../KeeperSdk && npm link ../keeperapi && cd ../examples/sdk_example && npm link ../../keeperapi", "types": "tsc --watch", - "types:ci": "tsc" + "types:ci": "tsc", + "build": "tsc" }, "dependencies": { - "@keeper-security/keeperapi": "17.1.0", - "@types/node": "^20.9.1", "ts-node": "^10.7.0", "tsconfig-paths": "^4.2.0", "typescript": "^4.6.3" + }, + "devDependencies": { + "@types/node": "^25.6.2" } } diff --git a/examples/sdk_example/src/auth/register_device.ts b/examples/sdk_example/src/auth/register_device.ts index 602ef4f4..2952be9f 100644 --- a/examples/sdk_example/src/auth/register_device.ts +++ b/examples/sdk_example/src/auth/register_device.ts @@ -1,70 +1,76 @@ import { KeeperVault, - SessionManager, KeeperSdkError, - loadKeeperConfig, - resolveServer, prompt, suppressLogs, cleanup, logger, - extractResultCode, SdkDefaults, ResultCodes, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' -async function registerDevice() { - const config = await loadKeeperConfig() - const defaultUsername = config.last_login || config.user || '' - let username: string - if (defaultUsername) { - logger.info(`Using saved username: ${defaultUsername}`) - username = defaultUsername - } else { - username = await prompt('Username (email): ') - if (!username) throw new KeeperSdkError('Username is required.', ResultCodes.MISSING_USERNAME) + +/** + * Store device token + device private key on a KeeperVault (in-memory for this process). + * Use before loginWithSessionToken when ~/.keeper/config.json has no device yet. + */ +async function main() { + const username = await prompt('Username (email): ') + if (!username) throw new KeeperSdkError('Username is required.', ResultCodes.MISSING_USERNAME) + + const host = (await prompt('Host [keepersecurity.com]: ')).trim() || 'keepersecurity.com' + const deviceToken = await prompt('Device token (base64 / base64url): ') + if (!deviceToken.trim()) { + throw new KeeperSdkError('Device token is required.', ResultCodes.INVALID_CREDENTIALS) } - const host = await resolveServer(username) - logger.info(`Host resolved to: ${host}`) - const sessionManager = new SessionManager() - const vault = new KeeperVault({ - host, - clientVersion: SdkDefaults.CLIENT_VERSION, - sessionStorage: sessionManager, - }) + const privateKey = await prompt('Device private key (base64 / base64url): ') + if (!privateKey.trim()) { + throw new KeeperSdkError('Device private key is required.', ResultCodes.INVALID_CREDENTIALS) + } + + const vault = new KeeperVault({ host, clientVersion: SdkDefaults.CLIENT_VERSION }) + try { - const MAX_ATTEMPTS = 5 - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - const password = await prompt('Password: ', true) - if (!password) throw new KeeperSdkError('Password is required.', ResultCodes.MISSING_PASSWORD) - const restore = suppressLogs() - try { - await vault.login(username, password) - restore() - break - } catch (err) { - restore() - const resultCode = extractResultCode(err) - if (resultCode === ResultCodes.INVALID_CREDENTIALS) { - const remaining = MAX_ATTEMPTS - attempt - if (remaining > 0) { - logger.warn(`Incorrect password (${remaining} attempt(s) remaining)`) - continue - } - throw new KeeperSdkError('Maximum attempts exceeded.', ResultCodes.MAX_ATTEMPTS_EXCEEDED) - } - throw err - } + await vault.registerDevice(deviceToken.trim(), privateKey.trim(), { username }) + logger.info(`Device credentials stored in memory for ${host} (this Node process).`) + logger.info('They are not written to ~/.keeper/config.json unless you use a full password login.\n') + + const tryLogin = (await prompt('Login with session token now? [y/N]: ')).trim().toLowerCase() + if (tryLogin !== 'y' && tryLogin !== 'yes') { + logger.info('Done. Call loginWithSessionToken on the same vault instance, or run auth:session-token.') + return + } + + const sessionToken = await prompt('Session token: ') + if (!sessionToken.trim()) { + throw new KeeperSdkError('Session token is required.', 'missing_session_token') + } + + logger.info(`\nLogging in as ${username} on ${host}...`) + const restore = suppressLogs() + try { + await vault.loginWithSessionToken(username, sessionToken.trim()) + } finally { + restore() + } + + logger.info('Syncing vault...') + const restoreSync = suppressLogs() + try { + await vault.sync() + } finally { + restoreSync() } + + const summary = vault.getSummary() const auth = vault.getAuth() - logger.info('\n--- Session Info ---') - logger.info(` Username: ${auth.username}`) - logger.info(` Server: ${vault.host}`) - logger.info(` Session Token: ${auth.sessionToken ? '(active)' : '(none)'}`) - logger.info(` Data Key: ${auth.dataKey ? '(loaded)' : '(not loaded)'}`) - logger.info('\nDevice registration complete.') + logger.info('--- Session Info ---') + logger.info(` Username: ${auth.username}`) + logger.info(` Records: ${summary.recordCount}`) + logger.info('\nLogin + sync successful.') } finally { cleanup(vault) } } -runExample(registerDevice) \ No newline at end of file + +runExample(main) diff --git a/examples/sdk_example/src/auth/restore_session.ts b/examples/sdk_example/src/auth/restore_session.ts new file mode 100644 index 00000000..e2dc3804 --- /dev/null +++ b/examples/sdk_example/src/auth/restore_session.ts @@ -0,0 +1,32 @@ +import { prompt, cleanup, logger } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { loginViaRestoreSession, requireSessionJsonPath } from '../utils/restoreAuth' + +async function main() { + const jsonPathInput = await prompt('Session JSON file path (required): ') + const jsonPath = requireSessionJsonPath(jsonPathInput, 'auth:restore-session') + + const host = (await prompt('Host [keepersecurity.com]: ')).trim() || 'keepersecurity.com' + const syncAnswer = (await prompt('Run syncDown after restore? [Y/n]: ')).trim().toLowerCase() + const runSync = syncAnswer !== 'n' && syncAnswer !== 'no' + + const vault = await loginViaRestoreSession({ + jsonPath, + host, + sync: runSync, + }) + + try { + const summary = vault.getSummary() + logger.info('--- Vault summary ---') + logger.info(` Records: ${summary.recordCount}`) + logger.info(` Shared folders: ${summary.sharedFolderCount}`) + logger.info(` Teams: ${summary.teamCount}`) + logger.info(` Folders: ${summary.folderCount}`) + logger.info('\nRestore complete.') + } finally { + cleanup(vault) + } +} + +runExample(main) diff --git a/examples/sdk_example/src/records/list_records.ts b/examples/sdk_example/src/records/list_records.ts index 269092b2..20359d1b 100644 --- a/examples/sdk_example/src/records/list_records.ts +++ b/examples/sdk_example/src/records/list_records.ts @@ -1,8 +1,17 @@ import { login, cleanup, formatRecord, logger } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' +import { assertRestoreCliArgs, loginViaRestoreSession, parseRestoreCliArgs } from '../utils/restoreAuth' async function listRecords() { - const vault = await login() + const cli = parseRestoreCliArgs() + assertRestoreCliArgs(cli) + const vault = cli.restoreSession + ? await loginViaRestoreSession({ + jsonPath: cli.jsonPath, + host: cli.host, + sync: !cli.noSync, + }) + : await login() try { const records = vault.getRecords() diff --git a/examples/sdk_example/src/records/list_records_shell_cli.ts b/examples/sdk_example/src/records/list_records_shell_cli.ts new file mode 100644 index 00000000..a25a095b --- /dev/null +++ b/examples/sdk_example/src/records/list_records_shell_cli.ts @@ -0,0 +1,31 @@ +import { cleanup, logger } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { assertRestoreCliArgs, parseRestoreCliArgs, requireSessionJsonPath } from '../utils/restoreAuth' +import { listRecordsViaShellCli, loginViaShellCliRestoreSession } from '../utils/shellCliRestore' + +// npm run records:list:shell-cli -- --from-json /path/to/session.json [--host keepersecurity.eu] +async function main() { + const cli = parseRestoreCliArgs() + if (!cli.restoreSession && !cli.jsonPath) { + throw new Error( + 'records:list:shell-cli requires --from-json /path/to/session.json (shell-style restore-session flow)' + ) + } + assertRestoreCliArgs({ ...cli, restoreSession: true }) + + const jsonPath = requireSessionJsonPath(cli.jsonPath, 'records:list:shell-cli') + const vault = await loginViaShellCliRestoreSession({ + jsonPath, + host: cli.host, + sync: !cli.noSync, + }) + + try { + const out = await listRecordsViaShellCli() + logger.info(out.trimEnd()) + } finally { + cleanup(vault) + } +} + +runExample(main) diff --git a/examples/sdk_example/src/utils/inMemoryConfigLoader.ts b/examples/sdk_example/src/utils/inMemoryConfigLoader.ts new file mode 100644 index 00000000..efcaa01e --- /dev/null +++ b/examples/sdk_example/src/utils/inMemoryConfigLoader.ts @@ -0,0 +1,16 @@ +import type { ConfigLoader, KeeperJsonConfig } from '@keeper-security/keeper-sdk-javascript' + +/** Process-lifetime session/device storage (no disk). */ +export class InMemoryConfigLoader implements ConfigLoader { + public readonly configDir = '' + + private data: KeeperJsonConfig = {} + + async load(): Promise { + return JSON.parse(JSON.stringify(this.data)) as KeeperJsonConfig + } + + async save(config: KeeperJsonConfig): Promise { + this.data = JSON.parse(JSON.stringify(config)) as KeeperJsonConfig + } +} diff --git a/examples/sdk_example/src/utils/restoreAuth.ts b/examples/sdk_example/src/utils/restoreAuth.ts new file mode 100644 index 00000000..bc09bd6c --- /dev/null +++ b/examples/sdk_example/src/utils/restoreAuth.ts @@ -0,0 +1,72 @@ +import * as path from 'path' +import { KeeperSdkError, ResultCodes, type KeeperVault } from '@keeper-security/keeper-sdk-javascript' +import { loginViaShellCliRestoreSession } from './shellCliRestore' + +export type RestoreSessionLoginOptions = { + /** Path to extension-style session JSON (required). */ + jsonPath: string + host?: string + /** Run syncDown after restore (default true). */ + sync?: boolean +} + +export function requireSessionJsonPath(jsonPath: string | undefined, context: string): string { + const trimmed = jsonPath?.trim() + if (!trimmed) { + throw new KeeperSdkError( + `${context}: session JSON path is required (no default). ` + + 'Pass --from-json /path/to/session.json or set RESTORE_SESSION_JSON.', + ResultCodes.INVALID_CREDENTIALS + ) + } + return path.resolve(trimmed.replace(/^~/, process.env.HOME || '')) +} + +/** + * Restore session via SDK CLI dispatch (same path as shellcomponent `restore-session`). + */ +export async function loginViaRestoreSession(options: RestoreSessionLoginOptions): Promise { + const jsonPath = requireSessionJsonPath(options.jsonPath, 'restore-session') + return loginViaShellCliRestoreSession({ + jsonPath, + host: options.host, + sync: options.sync, + }) +} + +export type RestoreCliArgs = { + restoreSession: boolean + jsonPath?: string + host?: string + noSync: boolean +} + +/** Parse `--restore-session`, `--from-json`, `--host`, `--no-sync` from process.argv. */ +export function parseRestoreCliArgs(argv: string[] = process.argv): RestoreCliArgs { + let restoreSession = false + let jsonPath: string | undefined + let host: string | undefined + let noSync = false + + for (let i = 2; i < argv.length; i++) { + const arg = argv[i] + if (arg === '--restore-session') { + restoreSession = true + } else if (arg === '--from-json' && argv[i + 1]) { + restoreSession = true + jsonPath = argv[++i] + } else if (arg === '--host' && argv[i + 1]) { + host = argv[++i] + } else if (arg === '--no-sync') { + noSync = true + } + } + + return { restoreSession, jsonPath, host, noSync } +} + +/** Validate CLI restore flags before loginViaRestoreSession. */ +export function assertRestoreCliArgs(cli: RestoreCliArgs): asserts cli is RestoreCliArgs & { jsonPath: string } { + if (!cli.restoreSession) return + requireSessionJsonPath(cli.jsonPath, 'records') +} diff --git a/examples/sdk_example/src/utils/shellCliHost.ts b/examples/sdk_example/src/utils/shellCliHost.ts new file mode 100644 index 00000000..35b35680 --- /dev/null +++ b/examples/sdk_example/src/utils/shellCliHost.ts @@ -0,0 +1,119 @@ +import * as fs from 'fs/promises' +import type { KeeperCliHost, KeeperCliVault } from '@keeper-security/keeper-sdk-javascript' +import { + KeeperVault, + LogLevel, + SessionManager, + SdkDefaults, +} from '@keeper-security/keeper-sdk-javascript' +import { InMemoryConfigLoader } from './inMemoryConfigLoader' + +type VaultInstance = KeeperVault + +let keeperHost: string | undefined +let vault: VaultInstance | null = null + +/** Override region/host (mirrors shell's `keeper-host` / `KEEPER_HOST`). */ +export function setExampleKeeperHost(host: string | undefined): void { + const trimmed = host?.trim() + keeperHost = trimmed || undefined + vault = null +} + +export function getExampleKeeperHost(): string | undefined { + if (keeperHost) return keeperHost + const fromEnv = (process.env.KEEPER_HOST || '').trim() + return fromEnv || undefined +} + +export function resetExampleShellVault(): void { + vault = null +} + +function getVault(): VaultInstance { + if (!vault) { + const host = getExampleKeeperHost() + vault = new KeeperVault({ + ...(host ? { host } : {}), + useConsoleAuth: false, + logLevel: LogLevel.WARN, + clientVersion: SdkDefaults.CLIENT_VERSION, + sessionStorage: new SessionManager(new InMemoryConfigLoader()), + }) + } + return vault +} + +/** Underlying vault (e.g. for `cleanup()` after CLI dispatch). */ +export function getExampleKeeperVault(): VaultInstance { + return getVault() +} + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e) +} + +function formatKeeperClientError(context: string, e: unknown): string { + return `${context}: ${errMsg(e)}\n` +} + +function asCliVault(v: VaultInstance): KeeperCliVault { + return { + get isLoggedIn() { + return v.isLoggedIn + }, + login: (u, p) => v.login(u, p), + loginWithSessionToken: (u, t) => v.loginWithSessionToken(u, t), + logout: () => v.logout(), + sync: () => v.sync(), + getRecords: () => v.getRecords(), + getSharedFolders: () => v.getSharedFolders(), + registerDevice: (dt, pk, o) => v.registerDevice(dt, pk, o), + restoreSession: (input) => v.restoreSession(input), + getSummary: () => v.getSummary(), + findRecord: (uidOrTitle) => v.findRecord(uidOrTitle), + findRecords: (criteria) => v.findRecords(criteria), + getRecordShareInfo: (uid) => v.getRecordShareInfo(uid), + listSharedFolders: (options) => v.listSharedFolders(options), + listFolder: (options) => v.listFolder(options), + tree: (options) => v.tree(options), + changeDirectory: (path) => v.changeDirectory(path), + getCurrentFolderUid: () => v.getCurrentFolderUid(), + getWorkingFolderDisplayName: () => v.getWorkingFolderDisplayName(), + getFolder: (uidOrName, options) => v.getFolder(uidOrName, options), + mkdir: (path, options) => v.mkdir(path, options), + renameFolder: (path, name) => v.renameFolder(path, name), + rmdir: (patterns, options) => v.rmdir(patterns, options), + listTeams: (options) => v.listTeams(options), + viewTeam: (id) => v.viewTeam(id), + listUsers: (options) => v.listUsers(options), + viewUser: (id) => v.viewUser(id), + } +} + +async function readTextFile(filePath: string): Promise { + const p = filePath.trim().replace(/^@/, '') + if (/^https?:\/\//i.test(p)) { + const res = await fetch(p) + if (!res.ok) { + throw new Error(`HTTP ${res.status} loading ${p}`) + } + return res.text() + } + const resolved = p.replace(/^~/, process.env.HOME || '') + return fs.readFile(resolved, 'utf8') +} + +function envString(name: string): string | undefined { + const v = process.env[name] + return typeof v === 'string' && v.length > 0 ? v : undefined +} + +/** Node `KeeperCliHost` (fs file-reads, no browser proxy). */ +export const exampleShellCliHost: KeeperCliHost = { + getVault: () => asCliVault(getVault()), + envString, + formatError: formatKeeperClientError, + readTextFile, + getAccountUsername: () => getVault().getAccountUsername(), +} diff --git a/examples/sdk_example/src/utils/shellCliRestore.ts b/examples/sdk_example/src/utils/shellCliRestore.ts new file mode 100644 index 00000000..01c17f41 --- /dev/null +++ b/examples/sdk_example/src/utils/shellCliRestore.ts @@ -0,0 +1,80 @@ +import * as path from 'path' +import { + dispatchCliLine, + KeeperSdkError, + logger, + ResultCodes, + type CliResult, + type KeeperVault, +} from '@keeper-security/keeper-sdk-javascript' +import { + exampleShellCliHost, + getExampleKeeperVault, + resetExampleShellVault, + setExampleKeeperHost, +} from './shellCliHost' + +export type ShellCliRestoreOptions = { + jsonPath: string + host?: string + /** Defaults to true. */ + sync?: boolean +} + +function resolveJsonPathForCli(jsonPath: string): string { + const expanded = jsonPath.trim().replace(/^~/, process.env.HOME || '') + return path.isAbsolute(expanded) ? expanded : path.resolve(expanded) +} + +export function buildRestoreSessionCliLine(jsonPath: string, sync: boolean): string { + const abs = resolveJsonPathForCli(jsonPath) + let line = `restore-session --from-json ${abs}` + if (sync) line += ' --sync' + return line +} + +function throwOnCliFailure(label: string, result: CliResult): void { + if (result.code === 0) return + const detail = (result.err || result.out).trim() + throw new KeeperSdkError( + `${label} failed (exit ${result.code})${detail ? `: ${detail}` : ''}`, + ResultCodes.INVALID_CREDENTIALS + ) +} + +/** Authenticate via SDK `restore-session` CLI (same dispatch path as shellcomponent). */ +export async function loginViaShellCliRestoreSession( + options: ShellCliRestoreOptions +): Promise { + const jsonPath = options.jsonPath + const runSync = options.sync !== false + + resetExampleShellVault() + setExampleKeeperHost(options.host) + + const line = buildRestoreSessionCliLine(jsonPath, runSync) + logger.info(`[shell-cli] ${line}`) + + const result = await dispatchCliLine(line, exampleShellCliHost) + throwOnCliFailure('restore-session', result) + if (result.out.trim()) { + logger.info(result.out.trimEnd()) + } + + const vault = getExampleKeeperVault() + if (!vault.isLoggedIn) { + throw new KeeperSdkError( + 'restore-session completed but vault is not logged in', + ResultCodes.SESSION_TOKEN_EXPIRED + ) + } + return vault +} + +/** Run `list` through CLI dispatch (shell-style listing). */ +export async function listRecordsViaShellCli(): Promise { + logger.info('[shell-cli] list') + const result = await dispatchCliLine('list', exampleShellCliHost) + throwOnCliFailure('list', result) + return result.out +} diff --git a/keeperapi/package-lock.json b/keeperapi/package-lock.json index 0f970399..c18db162 100644 --- a/keeperapi/package-lock.json +++ b/keeperapi/package-lock.json @@ -76,6 +76,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.9.tgz", "integrity": "sha512-G2EgeufBcYw27U4hhoIwFcgc1XU7TlXJ3mv04oOv1WCuo900U/anZSPzEqNjwdjgffkk2Gs0AN0dW1CKVLcG7w==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.22.5", @@ -1786,6 +1787,32 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2794,6 +2821,38 @@ "node": ">= 10" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@types/babel__core": { "version": "7.20.1", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz", @@ -2905,6 +2964,7 @@ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", "dev": true, + "peer": true, "dependencies": { "@types/linkify-it": "*", "@types/mdurl": "*" @@ -2921,6 +2981,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.2.tgz", "integrity": "sha512-37MXfxkb0vuIlRKHNxwCkb60PNBpR94u4efQuN4JgIAm66zfCDXGSAFCef9XUWFovX2R1ok6Z7MHhtdVXXkkIw==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -2979,6 +3040,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3362,6 +3424,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001541", "electron-to-chromium": "^1.4.535", @@ -3608,6 +3671,14 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4647,6 +4718,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.6.1.tgz", "integrity": "sha512-Nirw5B4nn69rVUZtemCQhwxOBhm0nsp3hmtF4rzCeWD7BkjAXRIji7xWQfnTNbz9g0aVsBX6aZK3n+23LM6uDw==", "dev": true, + "peer": true, "dependencies": { "@jest/core": "^29.6.1", "@jest/types": "^29.6.1", @@ -7292,7 +7364,7 @@ "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", "dev": true, "hasInstallScript": true, - "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", @@ -7669,7 +7741,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, - "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, @@ -8187,6 +8258,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8315,6 +8387,14 @@ "requires-port": "^1.0.0" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", @@ -8653,6 +8733,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.9.tgz", "integrity": "sha512-G2EgeufBcYw27U4hhoIwFcgc1XU7TlXJ3mv04oOv1WCuo900U/anZSPzEqNjwdjgffkk2Gs0AN0dW1CKVLcG7w==", "dev": true, + "peer": true, "requires": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.22.5", @@ -9817,6 +9898,28 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@cspotcode/source-map-support": { + "version": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "optional": true, + "requires": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "dependencies": { + "@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "optional": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + } + } + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -10584,6 +10687,30 @@ "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", "dev": true }, + "@tsconfig/node10": { + "version": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "optional": true + }, + "@tsconfig/node12": { + "version": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "optional": true + }, + "@tsconfig/node14": { + "version": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "optional": true + }, + "@tsconfig/node16": { + "version": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "optional": true + }, "@types/babel__core": { "version": "7.20.1", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz", @@ -10695,6 +10822,7 @@ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", "dev": true, + "peer": true, "requires": { "@types/linkify-it": "*", "@types/mdurl": "*" @@ -10711,6 +10839,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.2.tgz", "integrity": "sha512-37MXfxkb0vuIlRKHNxwCkb60PNBpR94u4efQuN4JgIAm66zfCDXGSAFCef9XUWFovX2R1ok6Z7MHhtdVXXkkIw==", "dev": true, + "peer": true, "requires": { "undici-types": "~5.26.4" } @@ -10767,7 +10896,8 @@ "version": "8.10.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", - "dev": true + "dev": true, + "peer": true }, "acorn-globals": { "version": "7.0.1", @@ -11055,6 +11185,7 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", "dev": true, + "peer": true, "requires": { "caniuse-lite": "^1.0.30001541", "electron-to-chromium": "^1.4.535", @@ -11230,6 +11361,12 @@ "browserslist": "^4.22.1" } }, + "create-require": { + "version": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "optional": true + }, "cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -11984,6 +12121,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.6.1.tgz", "integrity": "sha512-Nirw5B4nn69rVUZtemCQhwxOBhm0nsp3hmtF4rzCeWD7BkjAXRIji7xWQfnTNbz9g0aVsBX6aZK3n+23LM6uDw==", "dev": true, + "peer": true, "requires": { "@jest/core": "^29.6.1", "@jest/types": "^29.6.1", @@ -13969,6 +14107,7 @@ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", "dev": true, + "peer": true, "requires": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", @@ -14251,6 +14390,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, + "peer": true, "requires": { "fsevents": "~2.3.2" } @@ -14619,7 +14759,8 @@ "version": "4.6.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", - "dev": true + "dev": true, + "peer": true }, "uc.micro": { "version": "1.0.6", @@ -14699,6 +14840,12 @@ "requires-port": "^1.0.0" } }, + "v8-compile-cache-lib": { + "version": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "optional": true + }, "v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", diff --git a/keeperapi/package.json b/keeperapi/package.json index 566e4fd4..25c47b5c 100644 --- a/keeperapi/package.json +++ b/keeperapi/package.json @@ -17,7 +17,7 @@ "test": "jest", "types": "tsc --watch", "types:ci": "tsc", - "prepublishOnly": "rollup -c && cp src/proto.d.ts dist", + "prepublishOnly": "rollup -c && node ./scripts/copyProtoDts.js", "publish-to-npm": "npm publish" }, "dependencies": { diff --git a/keeperapi/scripts/copyProtoDts.js b/keeperapi/scripts/copyProtoDts.js new file mode 100644 index 00000000..e93e35c3 --- /dev/null +++ b/keeperapi/scripts/copyProtoDts.js @@ -0,0 +1,15 @@ +const fs = require('fs') +const path = require('path') + +const src = path.resolve(__dirname, '../src/proto.d.ts') +const distDir = path.resolve(__dirname, '../dist') +const dst = path.resolve(distDir, 'proto.d.ts') + +if (!fs.existsSync(src)) { + throw new Error(`Source file not found: ${src}`) +} + +fs.mkdirSync(distDir, { recursive: true }) +fs.copyFileSync(src, dst) + +console.log(`Copied ${src} -> ${dst}`) diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index 2a36bef4..5ff9ca57 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -558,6 +558,26 @@ export const getBackupMessage = ( export const getEnterprisePublicKeyMessage = (): RestOutMessage => createOutMessage('enterprise/get_enterprise_public_key', BreachWatch.EnterprisePublicKeyResponse) +export const getShareObjectsMessage = ( + data: Records.IGetShareObjectsRequest = {} +): RestMessage => + createMessage( + data, + 'vault/get_share_objects', + Records.GetShareObjectsRequest, + Records.GetShareObjectsResponse + ) + +export const getTeamMembersMessage = ( + data: Enterprise.IGetTeamMemberRequest +): RestMessage => + createMessage( + data, + 'vault/get_team_members', + Enterprise.GetTeamMemberRequest, + Enterprise.GetTeamMemberResponse + ) + export const getEnterpriseDataForUserMessage = ( data: Enterprise.IEnterpriseDataRequest ): RestMessage => diff --git a/shellcomponent/.env.example b/shellcomponent/.env.example new file mode 100644 index 00000000..acf00699 --- /dev/null +++ b/shellcomponent/.env.example @@ -0,0 +1,12 @@ +# Optional dev-only (Vite). Copy to .env.local — never commit secrets. +# Device pre-seed (same shape as ~/.keeper/config.json); username must match `login`. +# VITE_KEEPER_DEV_DEVICE_USER=you@example.com +# VITE_KEEPER_DEV_DEVICE_TOKEN= +# VITE_KEEPER_DEV_DEVICE_PRIVATE_KEY= + +# VITE_KEEPER_HOST=keepersecurity.eu +# VITE_KEEPER_USERNAME= +# VITE_KEEPER_SESSION_TOKEN= +# VITE_REGISTER_DEVICE_TOKEN= +# VITE_REGISTER_DEVICE_PRIVATE_KEY= +# VITE_RESTORE_SESSION_JSON= diff --git a/shellcomponent/.gitignore b/shellcomponent/.gitignore new file mode 100644 index 00000000..c643d230 --- /dev/null +++ b/shellcomponent/.gitignore @@ -0,0 +1,6 @@ +.env.local +**/.vite +**/node_modules +**/dist +**/build +**/coverage diff --git a/shellcomponent/README.md b/shellcomponent/README.md new file mode 100644 index 00000000..f46085db --- /dev/null +++ b/shellcomponent/README.md @@ -0,0 +1,158 @@ +# Keeper shell component (`@keeper-security/keeper-shell-component`) + +Web component that embeds the Keeper CLI in the browser: **``** and **``** (same behavior, two tag names). Commands run in-page via the Keeper JavaScript SDK and xterm.js. + +## Requirements + +- A modern browser with **custom elements** and **shadow DOM**. +- Serve the app over **HTTP(S)**. ES modules and the SDK do not run from `file://`. + +## Install + +```bash +npm install @keeper-security/keeper-shell-component +``` + +In this monorepo you can depend on the package with a **file** URL, for example `"file:../shellcomponent"`. Run **`npm run build`** in `shellcomponent` first so **`dist/`** exists for consumers that resolve the published **`exports`** entry. + +## Register the custom elements + +Import the package **once** before relying on `` or `` in the DOM. The entry module applies the **`buffer`** shim and registers both tags. + +```ts +import "@keeper-security/keeper-shell-component"; +``` + +TypeScript types are exposed via **`keeper-shell.d.ts`** (see **`package.json` → `exports`**). + +## Use in HTML + +```html + + + +``` + +Equivalent tag: + +```html + +``` + +## Use in a bundled app (Vite, webpack, etc.) + +```ts +// e.g. main.ts or app entry +import "@keeper-security/keeper-shell-component"; +``` + +Then render the tag from your framework or template. Example with JSX (tag name must be a string so the browser upgrades it to the custom element): + +```tsx +export function Page() { + return ( + + ); +} +``` + +If TypeScript complains about unknown intrinsic elements, extend **`JSX.IntrinsicElements`** for **`"web-console"`** / **`"keeper-shell"`**, or create the element with **`document.createElement("web-console")`** and attach it in a ref. + +## Attributes + +| Attribute | Description | +|-----------|-------------| +| **`height`** | Height of the terminal region (e.g. `360px`, `24rem`). Default **`320px`**. | +| **`collapsed`** | If present, the terminal panel starts hidden; only the shell toggle control is shown until the user opens it. | +| **`embed`** | If present, renders a full in-page terminal **without** the open/hide control. | +| **`keeper-host`** | Optional Keeper vault / region host override (e.g. `keepersecurity.eu`). | +| **`mask-input`** | If present, new prompts start with masked input (`*`); **Ctrl+O** toggles masking in the shell. | + +Boolean attributes follow HTML rules: include the attribute name to enable, omit to disable. + +## CLI commands (in-browser) + +The shell dispatches the Keeper SDK CLI (`dispatchCliLine`). Built-in commands include: + +| Command | Purpose | +|---------|---------| +| `help` | List commands or show help for one | +| `login` / `logout` | Authenticate (password via masked prompt; never on the CLI line) | +| `register-device` | Store device token + key for session-token login | +| `restore-session` | Resume from extension session JSON (`--from-json`) | +| `sync` | Vault sync (`sync-down`, `d`) | +| `vault summary` | Record / folder / team counts | +| `get` | Record by UID or title (`--format`, `--unmask`) | +| `list` | Vault records (uid + title) | +| `ls`, `cd`, `tree`, `mkdir` | Folder navigation | +| `search` | Find records by title | +| `list-sf` | Shared folders (`--verbose` for counts) | +| `list-team` | Enterprise teams | +| `whoami` | Current account | +| `users` | Enterprise `list`, `view` | + +Use ` --help` for full docs. Tab completes command names, subcommands, and flags. + +Write/mutate operations (add/update/delete users or teams, share record, etc.) are available on `KeeperVault` in code; CLI coverage focuses on list/get/view flows usable from the shell. + +## Programmatic API (optional) + +The package also exports helpers used by the shell (CLI dispatch, completion, vault helpers). See **`keeper-shell.d.ts`** and **`src/index.ts`** for **`dispatchCliLine`**, **`completeCliLine`**, **`setShellCliContext`**, **`resetShellVault`**, **`loginWithCredentials`**, **`loginWithSessionToken`**, and the **`KeeperShell`** / **`WebConsoleElement`** classes. + +## Layout note (non-embed) + +When the open/hide chrome is shown (**not** **`embed`**), the toggle is **`position: fixed`** at the **bottom-left** of the viewport (`12px` from edges). When the panel is visible, extra bottom padding is reserved so the terminal is not covered. Avoid placing critical fixed UI in that corner. + +## UMD bundle + +**`dist/keeper-shell.umd.cjs`** is built for environments that expect a UMD script. Wire it according to your host page’s script loader; the ESM path **`dist/keeper-shell.es.js`** is the primary **`import`** target. + +## Local development + +From **`shellcomponent/`**: + +```bash +npm install +npm run dev +``` + +Open the **`http://localhost:5175`** (or whatever port Vite prints) URL—**not** `file://`. The dev page is **`index.html`**; it loads **`src/dev-bootstrap.ts`**, which registers the shell. + +The dev page runs the **Keeper SDK in the browser**. To avoid CORS locally, dev wires a **same-origin proxy** — see [`docs/SAME_ORIGIN_DEV.md`](docs/SAME_ORIGIN_DEV.md). Only `npm run dev` is needed. + +```text +restore-session --from-json /dev/keeper-session.json --sync +list +``` + +Set region on the element when needed: ``. + +```bash +npm run build +``` + +Runs **`build:deps`** (keeperapi + KeeperSdk) then Vite. Produces **`dist/keeper-shell.es.js`** and **`dist/keeper-shell.umd.cjs`** (~6–8 MB; Keeper SDK is bundled in-browser). + +## Production deployment (e.g. Keeper Web Vault / webpack) + +1. **Build and publish** — ship only `dist/`, `keeper-shell.d.ts`, and `package.json` `exports` (see `files` field). +2. **Import once** in your app entry: `import "@keeper-security/keeper-shell-component";` +3. **Render** `` or `` (JSX: use the string tag name `"web-console"`). +4. **Region** — set `keeper-host` when not on US prod (e.g. `keepersecurity.eu`). +5. **Networking** — the SDK calls Keeper REST/WSS from the browser. Your origin must allow it (CORS) or proxy Keeper through your app origin (same pattern as [`docs/SAME_ORIGIN_DEV.md`](docs/SAME_ORIGIN_DEV.md), implemented in your gateway/nginx, not Vite). +6. **`restore-session --from-json`** — in production use inline JSON or an `https://` URL on your origin; local file paths work only in Vite dev (`/@fs`, `/dev/keeper-session.json`). + +The bundle is self-contained (no separate CLI server). Do not expect `remote` / `api-base` attributes — those were removed. + +## Security and networking + +Keeper API calls run from the user’s browser. Your page’s origin, CSP, and CORS (or a reverse proxy on your origin) must allow what the SDK needs. See [`docs/SAME_ORIGIN_DEV.md`](docs/SAME_ORIGIN_DEV.md) for the local dev proxy pattern. + +## License + +See **`package.json`** (`license` field). diff --git a/shellcomponent/index.html b/shellcomponent/index.html new file mode 100644 index 00000000..332ee899 --- /dev/null +++ b/shellcomponent/index.html @@ -0,0 +1,32 @@ + + + + + + Web console (dev) + + + +

Web console (dev)

+ + + + diff --git a/shellcomponent/keeper-shell.d.ts b/shellcomponent/keeper-shell.d.ts new file mode 100644 index 00000000..fc807815 --- /dev/null +++ b/shellcomponent/keeper-shell.d.ts @@ -0,0 +1,53 @@ +export type CliResult = { + code: number; + out: string; + err: string; + needPassword?: boolean; + loginUsername?: string; +}; + +export type ShellCliContext = { + keeperHost?: string; +}; + +export const KEEPER_SHELL_TAG: "keeper-shell"; +export const WEB_CONSOLE_TAG: "web-console"; + +export class KeeperShell extends HTMLElement { + keeperHost: string; + collapsed: boolean; + maskInput: boolean; + embed: boolean; + height: string; +} + +/** `` — extends {@link KeeperShell} (distinct class for dual registration). */ +export class WebConsoleElement extends KeeperShell {} + +/** Alias for {@link WebConsoleElement}. */ +export type WebConsole = WebConsoleElement; + +export function dispatchCliLine(line: string): Promise; +export function completeCliLine( + line: string, + options?: { loggedIn?: boolean; host?: import("@keeper-security/keeper-sdk-javascript").KeeperCliHost } +): { + base: string; + candidates: string[]; +}; + +export function setShellCliContext(next: ShellCliContext): void; +export function resetShellVault(): void; +export function loginWithCredentials(username: string, password: string): Promise; +export function loginWithSessionToken( + username: string, + sessionToken: string, + options?: { plainToken?: boolean } +): Promise; + +declare global { + interface HTMLElementTagNameMap { + "keeper-shell": KeeperShell; + "web-console": WebConsole; + } +} diff --git a/shellcomponent/package-lock.json b/shellcomponent/package-lock.json new file mode 100644 index 00000000..3f98f6c4 --- /dev/null +++ b/shellcomponent/package-lock.json @@ -0,0 +1,1443 @@ +{ + "name": "@keeper-security/keeper-shell-component", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@keeper-security/keeper-shell-component", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@keeper-security/keeper-sdk-javascript": "file:../KeeperSdk", + "@keeper-security/keeperapi": "file:../keeperapi", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "protobufjs": "^7.2.4" + }, + "devDependencies": { + "@types/http-proxy": "^1.17.15", + "@types/node": "^22.10.2", + "buffer": "^6.0.3", + "http-proxy": "^1.18.1", + "path-browserify": "^1.0.1", + "typescript": "~5.7.2", + "vite": "^6.0.3" + } + }, + "../keeperapi": { + "name": "@keeper-security/keeperapi", + "version": "17.2.2", + "license": "ISC", + "dependencies": { + "@noble/post-quantum": "^0.5.2", + "asmcrypto.js": "^2.3.2", + "faye-websocket": "^0.11.3", + "form-data": "^4.0.4", + "node-rsa": "^1.0.8" + }, + "devDependencies": { + "@babel/preset-env": "^7.23.5", + "@babel/preset-typescript": "^7.23.3", + "@rollup/plugin-commonjs": "^22.0.1", + "@rollup/plugin-node-resolve": "^13.3.0", + "@types/jest": "^24.0.15", + "@types/node": "^20.9.1", + "jest": "^29.6.1", + "jest-environment-jsdom": "^29.7.0", + "prettier": "^3.8.1", + "protobufjs": "^7.6.1", + "protobufjs-cli": "^1.3.1", + "rollup": "^2.79.2", + "rollup-plugin-typescript2": "^0.37.0", + "ts-jest": "^29.1.1", + "ts-node": "^8.10.2", + "typescript": "^4.0.1" + } + }, + "../KeeperSdk": { + "name": "@keeper-security/keeper-sdk-javascript", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@keeper-security/keeperapi": "file:../keeperapi", + "asmcrypto.js": "^2.3.2", + "ts-node": "^10.7.0", + "typescript": "^4.6.3" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "prettier": "^3.8.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@keeper-security/keeper-sdk-javascript": { + "resolved": "../KeeperSdk", + "link": true + }, + "node_modules/@keeper-security/keeperapi": { + "resolved": "../keeperapi", + "link": true + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/protobufjs": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.0.tgz", + "integrity": "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/shellcomponent/package.json b/shellcomponent/package.json new file mode 100644 index 00000000..63469686 --- /dev/null +++ b/shellcomponent/package.json @@ -0,0 +1,55 @@ +{ + "name": "@keeper-security/keeper-shell-component", + "version": "1.0.0", + "description": "Keeper web console: / + CLI + Keeper JavaScript SDK (in-browser).", + "type": "module", + "main": "./dist/keeper-shell.es.js", + "module": "./dist/keeper-shell.es.js", + "types": "./keeper-shell.d.ts", + "exports": { + ".": { + "types": "./keeper-shell.d.ts", + "import": "./dist/keeper-shell.es.js", + "require": "./dist/keeper-shell.umd.cjs" + } + }, + "files": [ + "dist", + "keeper-shell.d.ts" + ], + "scripts": { + "clean": "rm -rf dist node_modules", + "rebuild": "npm run clean && npm install && npm run build:deps && npm run build", + "build:deps": "npm --prefix ../keeperapi run build && npm --prefix ../KeeperSdk run build", + "build:local-keeper-sdk": "npm --prefix ../KeeperSdk install && npm --prefix ../KeeperSdk run build", + "dev": "npm install && vite", + "dev:inspect": "npm install && node --inspect=9229 ./node_modules/vite/bin/vite.js", + "build": "npm run build:deps && vite build", + "prepublishOnly": "npm run build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@keeper-security/keeper-sdk-javascript": "file:../KeeperSdk", + "@keeper-security/keeperapi": "file:../keeperapi", + "protobufjs": "^7.2.4", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "buffer": "^6.0.3", + "http-proxy": "^1.18.1", + "@types/http-proxy": "^1.17.15", + "path-browserify": "^1.0.1", + "typescript": "~5.7.2", + "vite": "^6.0.3" + }, + "keywords": [ + "keeper", + "web-components", + "cli", + "xterm" + ], + "license": "ISC" +} diff --git a/shellcomponent/src/KeeperShell.ts b/shellcomponent/src/KeeperShell.ts new file mode 100644 index 00000000..373859e8 --- /dev/null +++ b/shellcomponent/src/KeeperShell.ts @@ -0,0 +1,1105 @@ +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import xtermCss from "@xterm/xterm/css/xterm.css?inline"; + +import { completeCliLine } from "./cli/cliComplete.js"; +import { getKeeperCliPromptPrefix } from "@keeper-security/keeper-sdk-javascript"; +import { dispatchCliLine } from "./cli/cliDispatch.js"; +import { getShellKeeperHost } from "./cli/cliContext.js"; +import { shellKeeperCliHost } from "./cli/keeperCliHost.js"; +import { writeShellWelcome } from "./cli/shellBanner.js"; +import { setShellCliContext } from "./cli/cliContext.js"; +import { loginWithCredentials, resetShellVault } from "./cli/keeperCommands.js"; + +export const KEEPER_SHELL_TAG = "keeper-shell"; +/** Legacy custom element name (same behavior as {@link KEEPER_SHELL_TAG}). */ +export const WEB_CONSOLE_TAG = "web-console"; + +const ATTR_KEEPER_HOST = "keeper-host"; +const ATTR_COLLAPSED = "collapsed"; +const ATTR_HEIGHT = "height"; +/** Full in-page terminal only (no show/hide button). */ +const ATTR_EMBED = "embed"; +/** When set, each new `$ ` prompt starts with masked input (`*` display). Toggle with Ctrl+O. */ +const ATTR_MASK_INPUT = "mask-input"; + +/** Same icon for both states; `aria-label` / `title` reflect expand vs collapse. */ +function setConsoleToggleButtonUi(btn: HTMLButtonElement, collapsed: boolean): void { + const label = collapsed ? "Open console" : "Hide console"; + btn.setAttribute("aria-label", label); + btn.title = label; + btn.innerHTML = + ''; +} + +type CliResponse = { + out?: string; + err?: string; + error?: string; + code?: number; + needPassword?: boolean; + loginUsername?: string; +}; + +type CompleteResponse = { base?: unknown; candidates?: unknown }; + +function formatErr(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +function longestCommonPrefix(values: string[]): string { + if (values.length === 0) return ""; + let pref = values[0]; + for (let i = 1; i < values.length; i++) { + const s = values[i]; + let j = 0; + const n = Math.min(pref.length, s.length); + while (j < n && pref[j] === s[j]) j++; + pref = pref.slice(0, j); + if (pref === "") break; + } + return pref; +} + +/** Fallback when SDK prompt helper is unavailable. */ +const FALLBACK_PROMPT_PREFIX = "$ "; + +/** Screen rows used by prompt + input when the line wraps (cols from xterm). */ +function promptDisplayRows(displayChars: number, cols: number): number { + const width = Math.max(cols, 1); + if (displayChars <= 0) return 1; + return Math.ceil(displayChars / width); +} + +/** Parsed stdin tokens from xterm (arrow keys arrive as ESC sequences). */ +type InputTok = + | { k: "c"; v: string } + | { k: "up" } + | { k: "down" } + | { k: "left" } + | { k: "right" } + | { k: "del" }; + +function feedInput(chunk: string, carry: { s: string }): InputTok[] { + const s = carry.s + chunk; + carry.s = ""; + const out: InputTok[] = []; + let i = 0; + while (i < s.length) { + if (s.charCodeAt(i) !== 0x1b) { + out.push({ k: "c", v: s[i] }); + i++; + continue; + } + if (i + 1 >= s.length) { + carry.s = s.slice(i); + break; + } + const c1 = s[i + 1]; + if (c1 === "[") { + let j = i + 2; + let foundFinal = false; + while (j < s.length) { + const code = s.charCodeAt(j); + if (code >= 0x40 && code <= 0x7e) { + foundFinal = true; + const fn = s[j]; + const param = s.slice(i + 2, j); + if (fn === "A") out.push({ k: "up" }); + else if (fn === "B") out.push({ k: "down" }); + else if (fn === "C") out.push({ k: "right" }); + else if (fn === "D") out.push({ k: "left" }); + else if (fn === "~" && param === "3") out.push({ k: "del" }); + i = j + 1; + break; + } + j++; + } + if (!foundFinal) { + carry.s = s.slice(i); + break; + } + continue; + } + if (c1 === "O") { + if (i + 2 >= s.length) { + carry.s = s.slice(i); + break; + } + const c2 = s[i + 2]; + if (c2 === "A") out.push({ k: "up" }); + else if (c2 === "B") out.push({ k: "down" }); + else if (c2 === "C") out.push({ k: "right" }); + else if (c2 === "D") out.push({ k: "left" }); + else { + out.push({ k: "c", v: "\x1b" }); + i++; + continue; + } + i += 3; + continue; + } + out.push({ k: "c", v: "\x1b" }); + i++; + } + return out; +} + +export class KeeperShell extends HTMLElement { + static observedAttributes = [ + ATTR_KEEPER_HOST, + ATTR_COLLAPSED, + ATTR_HEIGHT, + ATTR_EMBED, + ATTR_MASK_INPUT, + ]; + + private _term: Terminal | null = null; + private _fit: FitAddon | null = null; + private _ro: ResizeObserver | null = null; + private _onWinResize: (() => void) | null = null; + private _chain: Promise = Promise.resolve(); + private _lineBuf = ""; + private _started = false; + private _completing = false; + private _inputListeners: AbortController | null = null; + /** Last `keeper-host` synced to {@link resetShellVault}; `undefined` = never synced. */ + private _syncedKeeperHostKey: string | undefined; + + constructor() { + super(); + // Lets the browser delegate focus to the xterm textarea inside shadow (keyboard input). + this.attachShadow({ mode: "open", delegatesFocus: true }); + } + + get keeperHost(): string { + return (this.getAttribute(ATTR_KEEPER_HOST) || "").trim(); + } + + set keeperHost(v: string) { + if (!v.trim()) this.removeAttribute(ATTR_KEEPER_HOST); + else this.setAttribute(ATTR_KEEPER_HOST, v); + } + + get collapsed(): boolean { + return this.hasAttribute(ATTR_COLLAPSED); + } + + set collapsed(v: boolean) { + if (v) this.setAttribute(ATTR_COLLAPSED, ""); + else this.removeAttribute(ATTR_COLLAPSED); + } + + get maskInput(): boolean { + return this.hasAttribute(ATTR_MASK_INPUT); + } + + set maskInput(v: boolean) { + if (v) this.setAttribute(ATTR_MASK_INPUT, ""); + else this.removeAttribute(ATTR_MASK_INPUT); + } + + /** Browser CLI layout: terminal fills the element; no collapsible chrome. */ + get embed(): boolean { + return this.hasAttribute(ATTR_EMBED); + } + + set embed(v: boolean) { + if (v) this.setAttribute(ATTR_EMBED, ""); + else this.removeAttribute(ATTR_EMBED); + } + + private _isEmbed(): boolean { + return this.hasAttribute(ATTR_EMBED); + } + + private _syncShellContext(): void { + const h = this.getAttribute(ATTR_KEEPER_HOST)?.trim() || ""; + setShellCliContext({ keeperHost: h || undefined }); + if (this._syncedKeeperHostKey !== h) { + this._syncedKeeperHostKey = h; + resetShellVault(); + } + } + + attributeChangedCallback(name: string, _old: string | null, val: string | null): void { + if (name === ATTR_HEIGHT && this.shadowRoot && !this._isEmbed()) { + const host = this.shadowRoot.querySelector(".wc-terminal-host"); + if (host instanceof HTMLElement) host.style.height = val || "320px"; + } + if (name === ATTR_KEEPER_HOST) { + this._syncShellContext(); + } + if (name === ATTR_EMBED) { + const shouldShow = this._isEmbed() || !this.collapsed; + if (this._started) this._teardownTerminal(); + this._renderShell(); + this._wireChrome(); + if (shouldShow) this._mountTerminal(); + return; + } + if (name === ATTR_COLLAPSED && !this._isEmbed() && this.shadowRoot) { + if (this.collapsed) this._hidePanel(); + else this._showPanel(); + } + } + + connectedCallback(): void { + if (!this.shadowRoot) return; + while (this.firstChild) { + this.removeChild(this.firstChild); + } + // Do not make the host a tab stop — xterm's inner textarea (`.wc-terminal-host`, tabindex=0) must receive keys. + this.tabIndex = -1; + this.setAttribute("role", "application"); + this.setAttribute("aria-label", "Keeper CLI"); + this._syncShellContext(); + this._renderShell(); + this._wireChrome(); + if (this._isEmbed() || !this.collapsed) { + this._mountTerminal(); + } + } + + disconnectedCallback(): void { + this._teardownTerminal(); + resetShellVault(); + this._syncedKeeperHostKey = undefined; + } + + private _wireChrome(): void { + if (!this.shadowRoot || this._isEmbed()) return; + const btn = this.shadowRoot.querySelector(".wc-toggle"); + if (!(btn instanceof HTMLButtonElement)) return; + btn.addEventListener("click", () => this._toggle()); + } + + private _toggle(): void { + if (this._isEmbed()) return; + const panel = this.shadowRoot?.querySelector(".wc-panel"); + if (!(panel instanceof HTMLElement)) return; + if (panel.hasAttribute("hidden")) { + this.removeAttribute(ATTR_COLLAPSED); + this._showPanel(); + } else { + this.setAttribute(ATTR_COLLAPSED, ""); + this._hidePanel(); + } + } + + /** Hide the terminal panel but keep xterm session (scrollback, history, current line). */ + private _hidePanel(): void { + const panel = this.shadowRoot?.querySelector(".wc-panel"); + const btn = this.shadowRoot?.querySelector(".wc-toggle"); + if (panel instanceof HTMLElement) panel.setAttribute("hidden", ""); + if (btn instanceof HTMLButtonElement) setConsoleToggleButtonUi(btn, true); + if (this._term) this._term.options.cursorBlink = false; + this._applyHostShellMinHeight(); + } + + /** Show the panel; mount on first open, otherwise refit and focus the existing terminal. */ + private _showPanel(): void { + const panel = this.shadowRoot?.querySelector(".wc-panel"); + const btn = this.shadowRoot?.querySelector(".wc-toggle"); + if (panel instanceof HTMLElement) panel.removeAttribute("hidden"); + if (btn instanceof HTMLButtonElement) setConsoleToggleButtonUi(btn, false); + if (!this._started) { + this._mountTerminal(); + } else { + this._resumeTerminal(); + } + this._applyHostShellMinHeight(); + } + + private _resumeTerminal(): void { + const term = this._term; + const fit = this._fit; + if (!term || !fit) return; + term.options.cursorBlink = true; + const refit = (): void => { + try { + fit.fit(); + if (term.cols < 2 || term.rows < 1) { + term.resize(80, 24); + } + term.focus(); + } catch { + /* detached or zero-size host */ + } + }; + requestAnimationFrame(() => { + refit(); + requestAnimationFrame(refit); + }); + } + + private _applyHostShellMinHeight(): void { + if (this._isEmbed()) return; + const panel = this.shadowRoot?.querySelector(".wc-panel"); + const panelHidden = panel?.hasAttribute("hidden"); + const h = (this.getAttribute(ATTR_HEIGHT) || "320px").trim(); + if (panelHidden) { + this.style.minHeight = "auto"; + return; + } + if (/^\d+(\.\d+)?px$/.test(h) || /^\d+(\.\d+)?rem$/.test(h)) { + this.style.minHeight = `calc(${h} + 3.75rem)`; + } else { + this.style.minHeight = "min(90vh, 28rem)"; + } + } + + private _renderShell(): void { + const height = this.getAttribute(ATTR_HEIGHT) || "320px"; + if (!this.shadowRoot) return; + this.shadowRoot.innerHTML = ""; + + const style = document.createElement("style"); + const embed = this._isEmbed(); + + if (embed) { + style.textContent = ` + ${xtermCss} + :host { + display: flex; + flex-direction: column; + width: 100%; + min-height: ${height}; + box-sizing: border-box; + font-family: system-ui, sans-serif; + pointer-events: auto; + } + .wc-root { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + padding: 0; + pointer-events: auto; + } + .wc-panel { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + margin: 0; + pointer-events: auto; + border: 2px solid #3d3d3d; + border-radius: 0; + overflow: hidden; + background: #121212 !important; + background-color: #121212 !important; + } + .wc-cli-cap { + flex-shrink: 0; + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + color: #c8c8c8; + background: #1e1e1e !important; + border-bottom: 1px solid #404040; + user-select: none; + } + .wc-terminal-host { + flex: 1; + min-height: ${height}; + min-width: 280px; + width: 100%; + box-sizing: border-box; + padding: 6px 0; + position: relative; + cursor: text; + outline: none; + background: #0b0b0b !important; + background-color: #0b0b0b !important; + pointer-events: auto; + } + .wc-terminal-host .xterm { height: 100%; background-color: #0b0b0b !important; pointer-events: auto; } + .wc-terminal-host .xterm-viewport { background-color: #0b0b0b !important; } + .wc-terminal-host .xterm-screen { background-color: #0b0b0b !important; } + `; + } else { + style.textContent = ` + ${xtermCss} + :host { + display: block; + width: 100%; + box-sizing: border-box; + font-family: system-ui, sans-serif; + min-height: calc(${height} + 3.75rem); + pointer-events: auto; + } + .wc-root { + padding: 12px; + display: flex; + flex-direction: column; + gap: 0; + pointer-events: auto; + } + .wc-root:has(.wc-panel:not([hidden])) { + padding-bottom: 48px; + } + .wc-toggle { + box-sizing: border-box; + width: 30px; + height: 30px; + padding: 0; + margin: 0; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; + cursor: pointer; + border-radius: 6px; + border: 1px solid #c4c4c4; + background: #f0f0f0; + color: #2a2a2a; + flex-shrink: 0; + position: fixed; + bottom: 12px; + left: 12px; + z-index: 10000; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); + } + .wc-toggle:hover { + background: #e4e4e4; + border-color: #a8a8a8; + } + .wc-toggle:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; + } + .wc-toggle-icon { + display: block; + width: 22px; + height: 22px; + object-fit: contain; + flex-shrink: 0; + pointer-events: none; + user-select: none; + } + .wc-panel { + margin-top: 0; + display: flex; + flex-direction: column; + border: 2px solid #3d3d3d; + border-radius: 10px; + overflow: hidden; + background: #121212 !important; + background-color: #121212 !important; + box-shadow: 0 2px 12px rgba(0,0,0,0.25); + pointer-events: auto; + } + .wc-panel[hidden] { display: none; } + .wc-cli-cap { + flex-shrink: 0; + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + color: #c8c8c8; + background: #1e1e1e !important; + border-bottom: 1px solid #404040; + user-select: none; + } + .wc-terminal-host { + flex: 1; + min-height: ${height}; + min-width: 280px; + width: 100%; + box-sizing: border-box; + padding: 6px 0; + position: relative; + cursor: text; + outline: none; + background: #0b0b0b !important; + background-color: #0b0b0b !important; + pointer-events: auto; + } + .wc-terminal-host .xterm { height: 100%; background-color: #0b0b0b !important; pointer-events: auto; } + .wc-terminal-host .xterm-viewport { background-color: #0b0b0b !important; } + .wc-terminal-host .xterm-screen { background-color: #0b0b0b !important; } + `; + } + + const root = document.createElement("div"); + root.className = "wc-root"; + + const panel = document.createElement("div"); + panel.className = "wc-panel"; + if (!embed && this.collapsed) panel.setAttribute("hidden", ""); + + const cap = document.createElement("div"); + cap.className = "wc-cli-cap"; + cap.textContent = "Keeper CLI — in-browser SDK (click in the terminal, then type)"; + + const host = document.createElement("div"); + host.className = "wc-terminal-host"; + if (!embed) { + host.style.height = height; + } + host.style.backgroundColor = "#0b0b0b"; + + panel.appendChild(cap); + panel.appendChild(host); + + if (!embed) { + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "wc-toggle"; + setConsoleToggleButtonUi(toggle, this.collapsed); + root.appendChild(panel); + root.appendChild(toggle); + } else { + root.appendChild(panel); + } + this.shadowRoot.append(style, root); + + if (!embed) { + this.style.display = "block"; + this.style.width = "100%"; + this.style.boxSizing = "border-box"; + this._applyHostShellMinHeight(); + } else { + this.style.minHeight = "100%"; + this.style.display = "block"; + this.style.width = "100%"; + this.style.boxSizing = "border-box"; + } + } + + private _mountTerminal(): void { + if (this._started) return; + const host = this.shadowRoot?.querySelector(".wc-terminal-host"); + if (!(host instanceof HTMLElement)) return; + + const term = new Terminal({ + allowTransparency: false, + cursorBlink: true, + convertEol: true, + fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace', + fontSize: 14, + theme: { + background: "#0b0b0b", + foreground: "#eaeaea", + cursor: "#eaeaea", + }, + }); + + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(host); + + const refit = (): void => { + try { + fit.fit(); + } catch { + /* layout not ready */ + } + }; + + /** xterm FitAddon yields 0×0 if the container has no layout box yet. */ + const ensureUsableGeometry = (): void => { + refit(); + if (term.cols < 2 || term.rows < 1) { + try { + term.resize(80, 24); + } catch { + /* ignore */ + } + } + }; + + ensureUsableGeometry(); + + host.tabIndex = 0; + host.setAttribute("aria-label", "Command line"); + const focusTerm = (): void => { + term.focus(); + }; + this._inputListeners?.abort(); + this._inputListeners = new AbortController(); + const sig = this._inputListeners.signal; + host.addEventListener("pointerdown", focusTerm, { signal: sig }); + host.addEventListener("focusin", focusTerm, { signal: sig }); + this.addEventListener( + "focusin", + (ev) => { + if (ev.target === this) focusTerm(); + }, + { signal: sig } + ); + + this._term = term; + this._fit = fit; + this._started = true; + this._lineBuf = ""; + + const HISTORY_MAX = 500; + const history: string[] = []; + const inputCarry = { s: "" }; + let histFromEnd = -1; + let histDraft = ""; + let cursorPos = 0; + /** Rows on screen for the current prompt line (increases when input wraps). */ + let promptRows = 1; + let maskSensitive = this.hasAttribute(ATTR_MASK_INPUT); + let pendingLoginUsername: string | null = null; + + const maskDisplayActive = (): boolean => + maskSensitive || pendingLoginUsername !== null; + + const resetMaskAfterPasswordFlow = (): void => { + maskSensitive = this.hasAttribute(ATTR_MASK_INPUT); + }; + + const afterNewPrompt = (): void => { + if (this.hasAttribute(ATTR_MASK_INPUT)) maskSensitive = true; + }; + + const promptPrefix = (): string => { + try { + return getKeeperCliPromptPrefix(shellKeeperCliHost); + } catch { + return FALLBACK_PROMPT_PREFIX; + } + }; + + const writeFreshPrompt = (): void => { + promptRows = 1; + term.write(promptPrefix()); + afterNewPrompt(); + }; + + const clearPromptRows = (): void => { + term.write("\r\x1b[2K"); + for (let r = 1; r < promptRows; r++) { + term.write("\x1b[1A\r\x1b[2K"); + } + }; + + const echoChar = (ch: string): string => (maskDisplayActive() ? "*" : ch); + + const totalDisplayChars = (lineLen: number): number => promptPrefix().length + lineLen; + + const paintPromptLine = (): void => { + const line = this._lineBuf; + if (cursorPos < 0) cursorPos = 0; + if (cursorPos > line.length) cursorPos = line.length; + const visible = maskDisplayActive() ? "*".repeat(line.length) : line; + const prefix = promptPrefix(); + const displayChars = prefix.length + visible.length; + term.write(`${prefix}${visible}`); + promptRows = promptDisplayRows(displayChars, term.cols); + const back = line.length - cursorPos; + if (back > 0) term.write(`\x1b[${back}D`); + }; + + const writePromptLine = (): void => { + clearPromptRows(); + paintPromptLine(); + }; + + /** Extend the line at end without clearing the prompt (typing / tab completion). */ + const applyLineAtEnd = (newLine: string): void => { + const oldLine = this._lineBuf; + if (cursorPos !== oldLine.length) { + this._lineBuf = newLine; + cursorPos = newLine.length; + writePromptLine(); + return; + } + const suffix = newLine.slice(oldLine.length); + this._lineBuf = newLine; + cursorPos = newLine.length; + if (!suffix) return; + const oldRows = promptRows; + const newRows = promptDisplayRows(totalDisplayChars(this._lineBuf.length), term.cols); + if (newRows < oldRows) { + writePromptLine(); + return; + } + term.write(maskDisplayActive() ? "*".repeat(suffix.length) : suffix); + promptRows = newRows; + }; + + /** Append at end of line without clearing the whole prompt (avoids flicker). */ + const appendAtEnd = (ch: string): void => { + const oldRows = promptRows; + this._lineBuf += ch; + cursorPos = this._lineBuf.length; + const newRows = promptDisplayRows(totalDisplayChars(this._lineBuf.length), term.cols); + if (newRows < oldRows) { + writePromptLine(); + return; + } + term.write(echoChar(ch)); + promptRows = newRows; + }; + + /** Backspace at end of line without full redraw when possible. */ + const backspaceAtEnd = (): void => { + if (cursorPos <= 0) return; + const oldRows = promptRows; + const line = this._lineBuf; + if (cursorPos < line.length) { + this._lineBuf = line.slice(0, cursorPos - 1) + line.slice(cursorPos); + cursorPos--; + writePromptLine(); + return; + } + this._lineBuf = line.slice(0, -1); + cursorPos = this._lineBuf.length; + const newRows = promptDisplayRows(totalDisplayChars(this._lineBuf.length), term.cols); + if (newRows < oldRows) { + writePromptLine(); + return; + } + term.write("\b \b"); + promptRows = newRows; + }; + + const moveCursorLeft = (): void => { + if (cursorPos <= 0) { + term.write("\x07"); + return; + } + cursorPos--; + term.write("\x1b[D"); + }; + + const moveCursorRight = (): void => { + if (cursorPos >= this._lineBuf.length) { + term.write("\x07"); + return; + } + cursorPos++; + term.write("\x1b[C"); + }; + + const insertInMiddle = (ch: string): void => { + const line = this._lineBuf; + this._lineBuf = line.slice(0, cursorPos) + ch + line.slice(cursorPos); + cursorPos++; + writePromptLine(); + }; + + const deleteForward = (): void => { + const line = this._lineBuf; + if (cursorPos >= line.length) { + term.write("\x07"); + return; + } + this._lineBuf = line.slice(0, cursorPos) + line.slice(cursorPos + 1); + writePromptLine(); + }; + + const resetHistoryNav = (): void => { + histFromEnd = -1; + }; + + const bumpEditing = (): void => { + histFromEnd = -1; + }; + + const pushHistoryEntry = (cmd: string): void => { + if (!cmd) return; + history.push(cmd); + if (history.length > HISTORY_MAX) history.shift(); + }; + + const historyOlder = (): void => { + if (history.length === 0) { + term.write("\x07"); + return; + } + if (histFromEnd === -1) { + histDraft = this._lineBuf; + histFromEnd = 0; + } else if (histFromEnd < history.length - 1) { + histFromEnd++; + } else { + term.write("\x07"); + return; + } + this._lineBuf = history[history.length - 1 - histFromEnd] ?? ""; + cursorPos = this._lineBuf.length; + writePromptLine(); + }; + + const historyNewer = (): void => { + if (histFromEnd === -1) { + return; + } + if (histFromEnd > 0) { + histFromEnd--; + this._lineBuf = history[history.length - 1 - histFromEnd] ?? ""; + cursorPos = this._lineBuf.length; + writePromptLine(); + return; + } + histFromEnd = -1; + this._lineBuf = histDraft; + cursorPos = this._lineBuf.length; + writePromptLine(); + }; + + const runTabComplete = async (): Promise => { + if (pendingLoginUsername !== null) { + term.write("\x07"); + return; + } + if (this._completing) return; + this._completing = true; + try { + bumpEditing(); + const data = completeCliLine(this._lineBuf, { host: shellKeeperCliHost }) as CompleteResponse; + const base = typeof data.base === "string" ? data.base : ""; + const raw = data.candidates; + const candidates = Array.isArray(raw) + ? raw.filter((x): x is string => typeof x === "string") + : []; + const partial = this._lineBuf.slice(base.length); + + if (candidates.length === 0) { + term.write("\x07"); + return; + } + if (candidates.length === 1) { + applyLineAtEnd(base + candidates[0]); + return; + } + const lcp = longestCommonPrefix(candidates); + if (lcp.length > partial.length) { + applyLineAtEnd(base + lcp); + return; + } + term.writeln(""); + term.writeln(`\x1b[90m${candidates.join(" ")}\x1b[0m`); + paintPromptLine(); + } catch { + term.write("\x07"); + } finally { + this._completing = false; + } + }; + + const flushLine = async (): Promise => { + const cmd = this._lineBuf.trim(); + const skipHistory = maskDisplayActive(); + this._lineBuf = ""; + cursorPos = 0; + resetHistoryNav(); + histDraft = ""; + + if (pendingLoginUsername !== null) { + if (!cmd) { + term.writeln("\x1b[31mlogin: password required\x1b[0m"); + writeFreshPrompt(); + return; + } + const username = pendingLoginUsername; + pendingLoginUsername = null; + term.writeln("\x1b[90mSigning in…\x1b[0m"); + try { + const data = await loginWithCredentials(username, cmd); + if (data.out) term.write(data.out); + if (data.err) term.write(`\x1b[31m${data.err}\x1b[0m`); + } catch (err) { + term.write(`\x1b[31mError: ${formatErr(err)}\x1b[0m`); + } + resetMaskAfterPasswordFlow(); + writeFreshPrompt(); + return; + } + + if (!cmd) { + writeFreshPrompt(); + return; + } + if (!skipHistory) pushHistoryEntry(cmd); + + try { + const data = await dispatchCliLine(cmd); + if (data.needPassword === true && typeof data.loginUsername === "string") { + pendingLoginUsername = data.loginUsername; + maskSensitive = true; + term.writeln("\x1b[90mPassword (masked):\x1b[0m"); + writeFreshPrompt(); + return; + } + if (data.out) term.write(data.out); + if (data.err) term.write(`\x1b[31m${data.err}\x1b[0m`); + } catch (err) { + term.write(`\x1b[31mError: ${formatErr(err)}\x1b[0m`); + } + writeFreshPrompt(); + }; + + const handleDataChunk = async (data: string): Promise => { + const tokens = feedInput(data, inputCarry); + for (const tok of tokens) { + if (tok.k === "up") { + historyOlder(); + continue; + } + if (tok.k === "down") { + historyNewer(); + continue; + } + if (tok.k === "left") { + bumpEditing(); + moveCursorLeft(); + continue; + } + if (tok.k === "right") { + bumpEditing(); + moveCursorRight(); + continue; + } + if (tok.k === "del") { + bumpEditing(); + deleteForward(); + continue; + } + const ch = tok.v; + if (ch === "\r" || ch === "\n") { + term.write("\r\n"); + promptRows = 1; + await flushLine(); + continue; + } + if (ch === "\u007f" || ch === "\b") { + bumpEditing(); + backspaceAtEnd(); + continue; + } + if (ch === "\t") { + await runTabComplete(); + continue; + } + if (ch === "\x0f") { + if (pendingLoginUsername === null) { + maskSensitive = !maskSensitive; + } + writePromptLine(); + continue; + } + if (ch === "\x03") { + this._lineBuf = ""; + cursorPos = 0; + pendingLoginUsername = null; + resetHistoryNav(); + histDraft = ""; + term.write("^C\r\n"); + writeFreshPrompt(); + continue; + } + const code = ch.charCodeAt(0); + if (code < 32) continue; + bumpEditing(); + const line = this._lineBuf; + if (cursorPos === line.length) { + appendAtEnd(ch); + } else { + insertInMiddle(ch); + } + } + }; + + term.onData((data) => { + this._chain = this._chain.then(() => handleDataChunk(data)); + }); + + const paintWelcome = (): void => { + writeShellWelcome((line) => term.writeln(line), { + cols: term.cols, + loggedIn: shellKeeperCliHost.getVault().isLoggedIn, + keeperHost: getShellKeeperHost(), + }); + writeFreshPrompt(); + }; + + requestAnimationFrame(() => { + ensureUsableGeometry(); + requestAnimationFrame(() => { + ensureUsableGeometry(); + paintWelcome(); + term.focus(); + }); + }); + + const lateFocus = (): void => { + try { + fit.fit(); + if (term.cols < 2 || term.rows < 1) { + term.resize(80, 24); + } + term.focus(); + } catch { + /* ignore */ + } + }; + setTimeout(lateFocus, 50); + setTimeout(lateFocus, 300); + + this._ro = new ResizeObserver(() => { + try { + fit.fit(); + if (term.cols < 2 || term.rows < 1) { + term.resize(80, 24); + } + } catch { + /* detached */ + } + }); + this._ro.observe(host); + + this._onWinResize = () => { + try { + fit.fit(); + if (term.cols < 2 || term.rows < 1) { + term.resize(80, 24); + } + } catch { + /* ignore */ + } + }; + window.addEventListener("resize", this._onWinResize); + } + + private _teardownTerminal(): void { + this._inputListeners?.abort(); + this._inputListeners = null; + if (this._ro && this.shadowRoot) { + const host = this.shadowRoot.querySelector(".wc-terminal-host"); + if (host) this._ro.unobserve(host); + this._ro.disconnect(); + } + this._ro = null; + if (this._onWinResize) { + window.removeEventListener("resize", this._onWinResize); + this._onWinResize = null; + } + if (this._term) { + this._term.dispose(); + this._term = null; + this._fit = null; + } + this._started = false; + this._chain = Promise.resolve(); + } +} + +/** + * Same behavior as {@link KeeperShell}; separate class because a custom element + * constructor may only be registered once per registry (two tags ⇒ two classes). + */ +export class WebConsoleElement extends KeeperShell {} + +if (!customElements.get(KEEPER_SHELL_TAG)) { + customElements.define(KEEPER_SHELL_TAG, KeeperShell); +} +if (!customElements.get(WEB_CONSOLE_TAG)) { + customElements.define(WEB_CONSOLE_TAG, WebConsoleElement); +} diff --git a/shellcomponent/src/bufferPolyfill.ts b/shellcomponent/src/bufferPolyfill.ts new file mode 100644 index 00000000..3fa88e59 --- /dev/null +++ b/shellcomponent/src/bufferPolyfill.ts @@ -0,0 +1,6 @@ +import { Buffer } from "buffer"; + +const g = globalThis as typeof globalThis & { Buffer?: typeof Buffer }; +if (typeof g.Buffer === "undefined") { + g.Buffer = Buffer; +} diff --git a/shellcomponent/src/cli/cliComplete.ts b/shellcomponent/src/cli/cliComplete.ts new file mode 100644 index 00000000..1595c994 --- /dev/null +++ b/shellcomponent/src/cli/cliComplete.ts @@ -0,0 +1,97 @@ +/** + * Tab-completion for keeper-shell (SDK command registry). + */ +import { + getCliCommand, + listCliCommandNamesForLoginState, + type KeeperCliHost, +} from "@keeper-security/keeper-sdk-javascript"; + +const HELP_FLAGS = ["--help", "-h"] as const; + +function flagsFor(cmd: string): readonly string[] { + const def = getCliCommand(cmd); + const extra = def?.flagOptions ?? []; + return [...HELP_FLAGS, ...extra]; +} + +export type CliCompleteResult = { + base: string; + candidates: string[]; +}; + +export type CliCompleteOptions = { + loggedIn?: boolean + host?: KeeperCliHost +} + +export function completeCliLine(line: string, options?: CliCompleteOptions): CliCompleteResult { + const loggedIn = options?.host?.getVault().isLoggedIn ?? options?.loggedIn ?? true + const completesNewWord = /\s$/.test(line); + const segments = line.match(/\S+/g) ?? []; + + let words: string[]; + let stub: string; + + if (completesNewWord) { + words = [...segments]; + stub = ""; + } else if (segments.length === 0) { + words = []; + stub = ""; + } else { + words = segments.slice(0, -1); + stub = segments[segments.length - 1] ?? ""; + } + + const lc = (s: string) => s.toLowerCase(); + const stubLc = lc(stub); + + const baseFor = (partialLen: number) => + partialLen > 0 ? line.slice(0, line.length - partialLen) : line; + + if (words.length === 0) { + const top = [...listCliCommandNamesForLoginState(loggedIn)]; + const hits = top.filter((c) => c.startsWith(stubLc)); + return { base: baseFor(stub.length), candidates: hits }; + } + + const cmd = lc(words[0]); + const def = getCliCommand(cmd); + + if (words.length === 1) { + if (stub.startsWith("-")) { + const hits = flagsFor(cmd).filter((f) => lc(f).startsWith(stubLc)); + return { base: baseFor(stub.length), candidates: [...hits] }; + } + const subs = def?.subcommands; + if (subs?.length) { + const hits = subs.filter((s) => lc(s).startsWith(stubLc)); + if (hits.length > 0) { + return { base: baseFor(stub.length), candidates: [...hits] }; + } + } + if (completesNewWord || stub.length > 0) { + const hits = flagsFor(cmd).filter((f) => lc(f).startsWith(stubLc)); + return { base: baseFor(stub.length), candidates: [...hits] }; + } + } + + if (words.length >= 2 && def?.subcommands) { + const subLc = lc(words[1]); + const knownSub = def.subcommands.some((s) => lc(s) === subLc); + if (knownSub && (stub.startsWith("-") || completesNewWord)) { + const hits = flagsFor(cmd).filter((f) => lc(f).startsWith(stubLc)); + return { base: baseFor(stub.length), candidates: [...hits] }; + } + } + + if (words.length >= 2 && !def?.subcommands && (stub.startsWith("-") || completesNewWord)) { + const hits = flagsFor(cmd).filter((f) => lc(f).startsWith(stubLc)); + if (hits.length > 0) { + return { base: baseFor(stub.length), candidates: [...hits] }; + } + } + + return { base: line, candidates: [] }; +} diff --git a/shellcomponent/src/cli/cliContext.ts b/shellcomponent/src/cli/cliContext.ts new file mode 100644 index 00000000..caeec3db --- /dev/null +++ b/shellcomponent/src/cli/cliContext.ts @@ -0,0 +1,51 @@ +export type ShellCliContext = { + /** Optional Keeper host / region (overrides KEEPER_HOST when set). */ + keeperHost?: string; +}; + +let ctx: ShellCliContext = {}; + +export function setShellCliContext(next: ShellCliContext): void { + ctx = { ...next }; +} + +export function getShellKeeperHost(): string | undefined { + const fromCtx = ctx.keeperHost?.trim(); + if (fromCtx) return fromCtx; + if (typeof process !== "undefined" && process.env) { + const h = (process.env.KEEPER_HOST || "").trim(); + if (h) return h; + } + return undefined; +} + +const VITE_ENV_MAP: Record = { + KEEPER_USERNAME: "VITE_KEEPER_USERNAME", + KEEPER_PASSWORD: "VITE_KEEPER_PASSWORD", + KEEPER_SESSION_TOKEN: "VITE_KEEPER_SESSION_TOKEN", + KEEPER_HOST: "VITE_KEEPER_HOST", + REGISTER_DEVICE_TOKEN: "VITE_REGISTER_DEVICE_TOKEN", + REGISTER_DEVICE_PRIVATE_KEY: "VITE_REGISTER_DEVICE_PRIVATE_KEY", + RESTORE_SESSION_JSON: "VITE_RESTORE_SESSION_JSON", +}; + +function readViteEnv(name: string): string | undefined { + try { + const viteKey = VITE_ENV_MAP[name]; + if (!viteKey || typeof import.meta === "undefined" || !import.meta.env) return undefined; + const v = import.meta.env[viteKey as keyof ImportMetaEnv] as string | undefined; + const t = typeof v === "string" ? v.trim() : ""; + return t.length > 0 ? t : undefined; + } catch { + return undefined; + } +} + +/** Node `process.env` in SSR/build; `import.meta.env.VITE_*` in the browser dev bundle. */ +export function envString(name: string): string | undefined { + if (typeof process !== "undefined" && process.env) { + const v = process.env[name]; + if (typeof v === "string" && v.length > 0) return v; + } + return readViteEnv(name); +} diff --git a/shellcomponent/src/cli/cliDispatch.ts b/shellcomponent/src/cli/cliDispatch.ts new file mode 100644 index 00000000..3976c181 --- /dev/null +++ b/shellcomponent/src/cli/cliDispatch.ts @@ -0,0 +1,23 @@ +import { + dispatchCliLine as sdkDispatchCliLine, + ensureKeeperCliRegistry, +} from "@keeper-security/keeper-sdk-javascript"; +import { shellKeeperCliHost } from "./keeperCliHost.js"; +import type { CliResult } from "./types.js"; + +ensureKeeperCliRegistry(); + +const rawMax = + typeof process !== "undefined" && process.env?.CLI_MAX_LINE_LENGTH + ? Number(process.env.CLI_MAX_LINE_LENGTH) + : NaN; +const MAX_LINE_LENGTH = Number.isFinite(rawMax) && rawMax > 0 ? rawMax : 8192; + +/** Shell entry: line-length guard, then SDK dispatch (restore-session uses raw `--from-json` tail + JSON.parse). */ +export async function dispatchCliLine(line: string): Promise { + if (line.length > MAX_LINE_LENGTH) { + return { code: 1, out: "", err: "line too long\n" }; + } + + return sdkDispatchCliLine(line, shellKeeperCliHost); +} diff --git a/shellcomponent/src/cli/inMemoryConfigLoader.ts b/shellcomponent/src/cli/inMemoryConfigLoader.ts new file mode 100644 index 00000000..5c589f48 --- /dev/null +++ b/shellcomponent/src/cli/inMemoryConfigLoader.ts @@ -0,0 +1,43 @@ +import type { ConfigLoader, KeeperJsonConfig } from "@keeper-security/keeper-sdk-javascript"; + +/** + * When `import.meta.env.DEV` is true and all three Vite env vars are set, preloads the same + * flat device shape as `~/.keeper/config.json` so {@link SessionManager.getDeviceConfig} returns + * a token/key before login — keeperapi `loginV3` then skips `registerDevice()`. + * Use the same username you pass to `login` as `VITE_KEEPER_DEV_DEVICE_USER` (case-insensitive match). + */ +function readDevSeededKeeperConfig(): KeeperJsonConfig { + if (import.meta.env?.DEV !== true) return {}; + const user = (import.meta.env.VITE_KEEPER_DEV_DEVICE_USER as string | undefined)?.trim(); + const device_token = (import.meta.env.VITE_KEEPER_DEV_DEVICE_TOKEN as string | undefined)?.trim(); + const private_key = (import.meta.env.VITE_KEEPER_DEV_DEVICE_PRIVATE_KEY as string | undefined)?.trim(); + if (!user || !device_token || !private_key) return {}; + return { + user, + last_login: user, + device_token, + private_key, + }; +} + +/** + * Session/device storage for {@link KeeperVault} inside the browser shell (no `.keeper` on disk). + */ +export class InMemoryConfigLoader implements ConfigLoader { + public readonly configDir = ""; + + private data: KeeperJsonConfig; + + constructor() { + const seed = readDevSeededKeeperConfig(); + this.data = Object.keys(seed).length > 0 ? seed : {}; + } + + async load(): Promise { + return JSON.parse(JSON.stringify(this.data)) as KeeperJsonConfig; + } + + async save(config: KeeperJsonConfig): Promise { + this.data = JSON.parse(JSON.stringify(config)) as KeeperJsonConfig; + } +} diff --git a/shellcomponent/src/cli/keeperCliHost.ts b/shellcomponent/src/cli/keeperCliHost.ts new file mode 100644 index 00000000..31749689 --- /dev/null +++ b/shellcomponent/src/cli/keeperCliHost.ts @@ -0,0 +1,178 @@ +import type { KeeperCliHost, KeeperCliVault } from "@keeper-security/keeper-sdk-javascript"; +import * as KeeperSdk from "@keeper-security/keeper-sdk-javascript"; +import { envString, getShellKeeperHost } from "./cliContext.js"; +import { InMemoryConfigLoader } from "./inMemoryConfigLoader.js"; + +const { KeeperVault, LogLevel, SessionManager, SdkDefaults } = KeeperSdk; + +type VaultInstance = InstanceType; + +let vault: VaultInstance | null = null; + +function getVault(): VaultInstance { + if (!vault) { + const host = getShellKeeperHost(); + vault = new KeeperVault({ + ...(host ? { host } : {}), + useConsoleAuth: false, + logLevel: LogLevel.WARN, + sessionStorage: new SessionManager(new InMemoryConfigLoader()), + }); + if (import.meta.env?.DEV === true) { + const devDevice = + import.meta.env.VITE_KEEPER_DEV_DEVICE_USER && + import.meta.env.VITE_KEEPER_DEV_DEVICE_TOKEN && + import.meta.env.VITE_KEEPER_DEV_DEVICE_PRIVATE_KEY; + console.info("[keeper-shell dev] KeeperVault created", { + host: host || "(SDK default; US prod host if unset)", + clientVersion: SdkDefaults.CLIENT_VERSION, + useConsoleAuth: false, + logLevel: "WARN", + preseededDeviceFromEnv: Boolean(devDevice), + }); + } + } + return vault; +} + +export function resetShellVault(): void { + vault = null; +} + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +function formatKeeperClientError(context: string, e: unknown): string { + const base = errMsg(e); + const inBrowser = typeof document !== "undefined"; + if (!inBrowser) { + return `${context}: ${base}\n`; + } + const low = base.toLowerCase(); + const fetchish = + low.includes("failed to fetch") || + low.includes("networkerror") || + low.includes("load failed") || + low.includes("network request failed"); + if (!fetchish) { + return `${context}: ${base}\n`; + } + const host = getShellKeeperHost(); + const hostHint = host + ? `\n Region/host in use: ${host} — wrong region can look like a network error; adjust keeper-host or KEEPER_HOST if needed.` + : `\n Region/host: default production (US). EU/AU/CA/JP tenants often need keeper-host on or KEEPER_HOST.`; + return ( + `${context}: ${base}\n` + + ` Browser could not reach Keeper (CORS, network, or wrong region).` + + hostHint + + `\n Dev: run shellcomponent with \`npm run dev\` (same-origin proxy). Prod: set keeper-host on the element or host-page proxy for Keeper API.\n` + ); +} + +function asCliVault(v: VaultInstance): KeeperCliVault { + return { + get isLoggedIn() { + return v.isLoggedIn; + }, + login: (u, p) => v.login(u, p), + loginWithSessionToken: (u, t) => v.loginWithSessionToken(u, t), + logout: () => v.logout(), + sync: () => v.sync(), + getRecords: () => v.getRecords(), + getSharedFolders: () => v.getSharedFolders(), + registerDevice: (dt, pk, o) => v.registerDevice(dt, pk, o), + restoreSession: (input) => v.restoreSession(input), + getSummary: () => v.getSummary(), + findRecord: (uidOrTitle) => v.findRecord(uidOrTitle), + findRecords: (criteria) => v.findRecords(criteria), + getRecordShareInfo: (uid) => v.getRecordShareInfo(uid), + listSharedFolders: (options) => v.listSharedFolders(options), + listFolder: (options) => v.listFolder(options), + tree: (options) => v.tree(options), + changeDirectory: (path) => v.changeDirectory(path), + getCurrentFolderUid: () => v.getCurrentFolderUid(), + getWorkingFolderDisplayName: () => v.getWorkingFolderDisplayName(), + getFolder: (uidOrName, options) => v.getFolder(uidOrName, options), + mkdir: (path, options) => v.mkdir(path, options), + renameFolder: (path, name) => v.renameFolder(path, name), + rmdir: (patterns, options) => v.rmdir(patterns, options), + listTeams: (options) => v.listTeams(options), + viewTeam: (id) => v.viewTeam(id), + listUsers: (options) => v.listUsers(options), + viewUser: (id) => v.viewUser(id), + }; +} + +function isHtmlLike(text: string): boolean { + const head = text.trimStart().slice(0, 32).toLowerCase(); + return head.startsWith(" { + const p = path.trim().replace(/^@/, ""); + + if (/^https?:\/\//i.test(p)) { + const res = await fetch(p); + if (!res.ok) { + throw new Error(`HTTP ${res.status} loading ${p}`); + } + const text = await res.text(); + if (isHtmlLike(text)) { + throw new Error(`URL returned HTML, not JSON: ${p}`); + } + return text; + } + + const isDev = import.meta.env?.DEV === true; + + const tryFetch = async (url: string): Promise => { + const res = await fetch(url); + if (!res.ok) return null; + const ct = res.headers.get("content-type") ?? ""; + if (ct.includes("text/html")) return null; + const text = await res.text(); + if (isHtmlLike(text)) return null; + return text; + }; + + if (isDev && p.startsWith("/")) { + const body = await tryFetch(`/@fs${p}`); + if (body !== null) return body; + throw new Error( + `Could not read ${p}. Run \`npm run dev\` in shellcomponent and ensure the path is in Vite fs.allow.` + ); + } + + if (isDev && (p === "conf.json" || p.endsWith("/conf.json"))) { + const fromDev = await tryFetch("/dev/keeper-session.json"); + if (fromDev !== null) return fromDev; + } + + if (p.startsWith("/") || p.startsWith("./") || p.startsWith("../")) { + throw new Error( + `Cannot read local file path in the browser: ${p}\n` + + ` Use restore-session --from-json with inline JSON, or an https:// URL your app serves.\n` + + (isDev ? ` Dev only: absolute path via Vite (/@fs…) or /dev/keeper-session.json.\n` : "") + ); + } + + const body = await tryFetch(p); + if (body !== null) return body; + + throw new Error( + `Could not load session JSON from ${p}.\n` + + ` Pass inline JSON to --from-json, or a URL that returns application/json.\n` + + (isDev + ? ` Dev: restore-session --from-json /dev/keeper-session.json or an absolute /@fs path.\n` + : ` Prod: host the session file on your origin or paste JSON into --from-json.\n`) + ); +} + +export const shellKeeperCliHost: KeeperCliHost = { + getVault: () => asCliVault(getVault()), + envString, + formatError: formatKeeperClientError, + readTextFile, + getAccountUsername: () => getVault().getAccountUsername(), +}; diff --git a/shellcomponent/src/cli/keeperCommands.ts b/shellcomponent/src/cli/keeperCommands.ts new file mode 100644 index 00000000..dcabc819 --- /dev/null +++ b/shellcomponent/src/cli/keeperCommands.ts @@ -0,0 +1,26 @@ +/** + * Shell exports for Keeper CLI login helpers (used by UI password transport). + */ +import { + loginWithCredentials as sdkLoginWithCredentials, + loginWithSessionToken as sdkLoginWithSessionToken, + type CliResult, +} from "@keeper-security/keeper-sdk-javascript"; +import { shellKeeperCliHost } from "./keeperCliHost.js"; + +export { resetShellVault } from "./keeperCliHost.js"; + +export async function loginWithCredentials(username: string, password: string): Promise { + return sdkLoginWithCredentials(shellKeeperCliHost, username, password); +} + +export async function loginWithSessionToken( + username: string, + sessionToken: string, + options?: { plainToken?: boolean } +): Promise { + return sdkLoginWithSessionToken(shellKeeperCliHost, username, sessionToken, options); +} + +/** @deprecated Use {@link loginWithSessionToken}. */ +export const loginWithSessionTokenCredentials = loginWithSessionToken; diff --git a/shellcomponent/src/cli/shellBanner.ts b/shellcomponent/src/cli/shellBanner.ts new file mode 100644 index 00000000..0fd116be --- /dev/null +++ b/shellcomponent/src/cli/shellBanner.ts @@ -0,0 +1,244 @@ +import { SdkDefaults } from "@keeper-security/keeper-sdk-javascript"; +import shellPkg from "../../package.json"; + +const ESC = { + reset: "\x1b[0m", + dim: "\x1b[2m", + yellow: "\x1b[93m", + green: "\x1b[32m", + white: "\x1b[97m", +}; + +export type ShellWelcomeOptions = { + cols: number; + loggedIn: boolean; + keeperHost?: string; + shellVersion?: string; + sdkVersion?: string; +}; + +/** Keeper lock + tagline (Commander display.welcome lines 1–7). */ +const KEEPER_ART = [ + " /#############/ /#\\ ", + " /#############/ /###\\ _ __ _______ _______ ______ _______ ______ (R)", + " /#############/ /#####\\ | | / / | ____/ | ____/ | ___ \\ | ____/ | ___ \\ ", + " /######/ \\######\\ | | / / | |____ | |____ | | __| || |____ | | __| | ", + " /######/ \\######\\ | |< < | ___/ | ___/ | |/___/ | ___/ | |/_ / ", + " /######/ \\######\\ | | \\ \\ | |_____ | |_____ | | | |_____ | | \\ \\ ", + " \\######\\ /######/ |_| \\_\\ |_______||_______||_| |_______||_| \\_\\ ", +] as const; + +/** Dual-banner rows (lock prefix + Commander word art), Commander lines 8–12. */ +const DUAL_BANNER_ROWS: ReadonlyArray = [ + [" \\######\\ /######/", " ____ _ "], + [" \\######\\ /######/ ", " / ___|___ _ __ ___ _ __ ___ __ _ _ __ __| | ___ _ __ "], + [" \\#############\\ \\#####/ ", " / / / _ \\| '_ ` _ \\| '_ ` _ \\ / _` | '_ \\ / _` |/ _ \\ '__| "], + [" \\#############\\ \\###/ ", " \\ \\__| (_) | | | | | | | | | | | (_| | | | | (_| | __/ | "], + [" \\#############\\ \\#/ ", " \\_____\\___/|_| |_| |_|_| |_| |_|\\__,_|_| |_|\\__,_|\\___|_| "], +] as const; + +/** Narrow terminal: lock graphic only (leading spaces preserved). */ +const NARROW_LOCK_ART = [ + " /#############/ /#\\ ", + " /#############/ /###\\", + " /#############/ /#####\\", + " /######/ \\######\\", + " /######/ \\######\\", + " /######/ \\######\\", + " \\######\\ /######/", + " \\######\\ /######/", + " \\######\\ /######/ ", + " \\#############\\ \\#####/ ", + " \\#############\\ \\###/ ", + " \\#############\\ \\#/ ", +] as const; + +type BannerTier = "wide" | "narrow" | "minimal"; + +/** Full dual banner needs ~118 cols; below that use lock-only art. */ +const TIER_WIDE_COLS = 118; +const TIER_NARROW_COLS = 48; + +const COMMANDER_VERSION_ALIGN_COL = 93; + +function bannerTier(cols: number): BannerTier { + if (cols >= TIER_WIDE_COLS) return "wide"; + if (cols >= TIER_NARROW_COLS) return "narrow"; + return "minimal"; +} + +/** Truncate at terminal edge only — never trim leading spaces (ASCII art alignment). */ +function fitArtLine(line: string, cols: number): string { + return line.length > cols ? line.slice(0, cols) : line; +} + +/** Word-wrap prose only (not ASCII art). */ +export function wrapPlainText(text: string, cols: number): string[] { + const width = Math.max(cols, 16); + const trimmed = text.trim(); + if (!trimmed) return []; + if (trimmed.length <= width) return [trimmed]; + + const words = trimmed.split(/\s+/); + const lines: string[] = []; + let current = ""; + + for (const word of words) { + if (word.length > width) { + if (current) { + lines.push(current); + current = ""; + } + for (let i = 0; i < word.length; i += width) { + lines.push(word.slice(i, i + width)); + } + continue; + } + const candidate = current ? `${current} ${word}` : word; + if (candidate.length > width) { + lines.push(current); + current = word; + } else { + current = candidate; + } + } + if (current) lines.push(current); + return lines; +} + +function yellow(text: string): string { + return `${ESC.yellow}${text}${ESC.reset}`; +} + +function white(text: string): string { + return `${ESC.white}${text}${ESC.reset}`; +} + +function dim(text: string): string { + return `${ESC.dim}${text}${ESC.reset}`; +} + +/** Commander display.welcome dual-line logic (truncate, never reflow). */ +function pushDualRow(out: string[], left: string, right: string, cols: number): void { + let yellowLine = left; + let whiteLine = right; + if (yellowLine.length > cols) { + yellowLine = yellowLine.slice(0, cols); + } + if (yellowLine.length + whiteLine.length > cols) { + if (yellowLine.length < cols) { + whiteLine = whiteLine.slice(0, cols - yellowLine.length); + } else { + whiteLine = ""; + } + } + if (whiteLine) { + out.push(`${ESC.yellow}${yellowLine}${ESC.white}${whiteLine}${ESC.reset}`); + } else { + out.push(yellow(yellowLine)); + } +} + +/** Responsive Commander banner — preserves fixed-width ASCII alignment. */ +export function bannerLines(cols: number): string[] { + const width = Math.max(cols, 24); + const tier = bannerTier(width); + const out: string[] = []; + + if (tier === "minimal") { + out.push(yellow("Keeper Commander Shell")); + return out; + } + + if (tier === "narrow") { + for (const line of NARROW_LOCK_ART) { + out.push(yellow(fitArtLine(line, width))); + } + out.push(white(fitArtLine("Commander", width))); + return out; + } + + for (const line of KEEPER_ART) { + out.push(yellow(fitArtLine(line, width))); + } + for (const [left, right] of DUAL_BANNER_ROWS) { + pushDualRow(out, left, right, width); + } + return out; +} + +function versionLine(shellVersion: string, sdkVersion: string, cols: number): string { + const label = `v${shellVersion} · SDK ${sdkVersion}`; + if (cols < 48) return dim(label); + const alignTo = Math.min(COMMANDER_VERSION_ALIGN_COL, cols); + const pad = Math.max(0, alignTo - label.length); + return `${ESC.dim}${" ".repeat(pad)}${label}${ESC.reset}`; +} + +function notLoggedInMessages(cols: number, keeperHost?: string): string[] { + const out: string[] = []; + out.push("You are not logged in."); + + if (keeperHost) { + out.push(...wrapPlainText(`Region: ${keeperHost}`, cols)); + } + + if (cols >= 100) { + out.push( + `Type ${ESC.green}login ${ESC.reset} to authenticate, ` + + `${ESC.green}restore-session${ESC.reset} to resume, or ` + + `${ESC.green}register-device${ESC.reset} for token login.` + ); + } else { + out.push(`Type ${ESC.green}login ${ESC.reset} to authenticate.`); + out.push( + `${ESC.green}restore-session${ESC.reset} to resume, or ` + + `${ESC.green}register-device${ESC.reset} for token login.` + ); + } + out.push(`Type ${ESC.green}help${ESC.reset} or ${ESC.green}?${ESC.reset} for sign-in commands.`); + return out; +} + +function loggedInMessages(cols: number): string[] { + const out: string[] = [ + `Type ${ESC.green}help${ESC.reset} or ${ESC.green}?${ESC.reset} for available commands.`, + ]; + for (const row of wrapPlainText( + "Vault shell — get, ls, cd, tree, search, sync-down (see COMMAND --help).", + cols + )) { + out.push(dim(row)); + } + return out; +} + +/** Commander-style startup banner + session hint (browser shell). */ +export function buildShellWelcomeLines(options: ShellWelcomeOptions): string[] { + const cols = Math.max(options.cols, 24); + const shellVersion = options.shellVersion ?? shellPkg.version; + const sdkVersion = options.sdkVersion ?? SdkDefaults.CLIENT_VERSION; + const lines: string[] = [""]; + + lines.push(...bannerLines(cols)); + lines.push(versionLine(shellVersion, sdkVersion, cols)); + lines.push(""); + + if (!options.loggedIn) { + lines.push(...notLoggedInMessages(cols, options.keeperHost)); + } else { + lines.push(...loggedInMessages(cols)); + } + + lines.push(""); + return lines; +} + +export function writeShellWelcome( + write: (line: string) => void, + options: ShellWelcomeOptions +): void { + for (const line of buildShellWelcomeLines(options)) { + write(line); + } +} diff --git a/shellcomponent/src/cli/types.ts b/shellcomponent/src/cli/types.ts new file mode 100644 index 00000000..abba04bf --- /dev/null +++ b/shellcomponent/src/cli/types.ts @@ -0,0 +1 @@ +export type { CliResult } from "@keeper-security/keeper-sdk-javascript"; diff --git a/shellcomponent/src/dev-bootstrap.ts b/shellcomponent/src/dev-bootstrap.ts new file mode 100644 index 00000000..3a302d1b --- /dev/null +++ b/shellcomponent/src/dev-bootstrap.ts @@ -0,0 +1,34 @@ +/** + * Dev page entry only. Dynamic-imports the shell so load failures surface in the page + * (static `import "./KeeperShell.js"` would fail the whole module with no UI feedback). + */ +import "./bufferPolyfill.js"; +import { installKeeperSameOriginProxy } from "./dev/installKeeperSameOriginProxy.js"; + +installKeeperSameOriginProxy(); + +function showShellLoadError(err: unknown): void { + const msg = err instanceof Error ? `${err.message}\n\n${err.stack ?? ""}` : String(err); + document.querySelectorAll("web-console, keeper-shell").forEach((node) => { + const el = node as HTMLElement; + while (el.firstChild) el.removeChild(el.firstChild); + const pre = document.createElement("pre"); + pre.style.cssText = + "display:block;margin:0;padding:16px;background:#2a0a0a;color:#fecaca;border:2px solid #991b1b;border-radius:8px;white-space:pre-wrap;font:13px/1.45 ui-monospace,monospace;"; + pre.textContent = `Keeper shell failed to load:\n\n${msg}\n`; + el.appendChild(pre); + el.style.display = "block"; + el.style.width = "100%"; + el.style.minHeight = "16rem"; + el.style.boxSizing = "border-box"; + }); +} + +void (async () => { + try { + await import("./KeeperShell.js"); + } catch (err: unknown) { + showShellLoadError(err); + console.error("[keeper-shell] failed to load", err); + } +})(); diff --git a/shellcomponent/src/dev/installKeeperSameOriginProxy.ts b/shellcomponent/src/dev/installKeeperSameOriginProxy.ts new file mode 100644 index 00000000..1e758a4e --- /dev/null +++ b/shellcomponent/src/dev/installKeeperSameOriginProxy.ts @@ -0,0 +1,66 @@ +import { + isAllowedKeeperProxyHost, + KEEPER_REST_PROXY_PREFIX, + KEEPER_WSS_PROXY_PREFIX, +} from "./keeperProxyHosts.js"; + +function shouldProxyUrl(url: URL): boolean { + return isAllowedKeeperProxyHost(url.hostname); +} + +function rewriteKeeperUrl(url: string): string { + try { + const u = new URL(url, typeof window !== "undefined" ? window.location.href : undefined); + if (!shouldProxyUrl(u)) return url; + + const ws = u.protocol === "wss:" || u.protocol === "ws:"; + const prefix = ws ? KEEPER_WSS_PROXY_PREFIX : KEEPER_REST_PROXY_PREFIX; + const proto = + window.location.protocol === "https:" + ? ws + ? "wss:" + : "https:" + : ws + ? "ws:" + : "http:"; + + return `${proto}//${window.location.host}${prefix}${u.host}${u.pathname}${u.search}`; + } catch { + return url; + } +} + +/** Dev: rewrite Keeper fetch/WebSocket URLs to the same-origin Vite proxy prefixes. */ +export function installKeeperSameOriginProxy(): void { + if (typeof window === "undefined") return; + + const nativeFetch = window.fetch.bind(window); + window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + if (typeof input === "string") { + return nativeFetch(rewriteKeeperUrl(input), init); + } + if (input instanceof Request) { + const next = rewriteKeeperUrl(input.url); + return next === input.url + ? nativeFetch(input, init) + : nativeFetch(new Request(next, input), init); + } + return nativeFetch(input, init); + }) as typeof fetch; + + const NativeWebSocket = window.WebSocket; + window.WebSocket = new Proxy(NativeWebSocket, { + construct(_target, args: [string | URL, (string | string[])?]) { + const [url, protocols] = args; + return new NativeWebSocket(rewriteKeeperUrl(String(url)), protocols); + }, + }) as typeof WebSocket; + + if (import.meta.env?.DEV === true) { + console.info( + "[keeper-shell] Same-origin dev proxy enabled:", + KEEPER_REST_PROXY_PREFIX, + KEEPER_WSS_PROXY_PREFIX + ); + } +} diff --git a/shellcomponent/src/dev/keeperProxyHosts.ts b/shellcomponent/src/dev/keeperProxyHosts.ts new file mode 100644 index 00000000..38ba2d8c --- /dev/null +++ b/shellcomponent/src/dev/keeperProxyHosts.ts @@ -0,0 +1,20 @@ +/** Allowlist of Keeper infra hosts the dev proxy may forward to. */ +export function isAllowedKeeperProxyHost(host: string): boolean { + if (!host || host.includes("..") || host.includes("/")) return false; + if (/^push\.services\./.test(host)) { + return isAllowedKeeperProxyHost(host.slice("push.services.".length)); + } + if (/^connect\./.test(host)) { + const rest = host.slice("connect.".length); + return ( + rest === "keepersecurity.us" || + /^[a-z0-9.-]+\.keepersecurity\.(com|eu|us)$/.test(rest) || + rest === "keepersecurity.com" || + rest === "keepersecurity.eu" + ); + } + return /^([a-z0-9.-]+\.)?keepersecurity\.(com|eu|us)$/.test(host); +} + +export const KEEPER_REST_PROXY_PREFIX = "/__keeper/"; +export const KEEPER_WSS_PROXY_PREFIX = "/__keeper-wss/"; diff --git a/shellcomponent/src/index.ts b/shellcomponent/src/index.ts new file mode 100644 index 00000000..f864268f --- /dev/null +++ b/shellcomponent/src/index.ts @@ -0,0 +1,21 @@ +import "./bufferPolyfill.js"; +/** Registers `` and ``. */ +import "./KeeperShell.js"; + +export { + KeeperShell, + WebConsoleElement, + WebConsoleElement as WebConsole, + KEEPER_SHELL_TAG, + WEB_CONSOLE_TAG, +} from "./KeeperShell.js"; +export { dispatchCliLine } from "./cli/cliDispatch.js"; +export { completeCliLine } from "./cli/cliComplete.js"; +export type { CliResult } from "./cli/types.js"; +export type { ShellCliContext } from "./cli/cliContext.js"; +export { setShellCliContext } from "./cli/cliContext.js"; +export { + resetShellVault, + loginWithCredentials, + loginWithSessionToken, +} from "./cli/keeperCommands.js"; diff --git a/shellcomponent/src/shims/fs-empty.ts b/shellcomponent/src/shims/fs-empty.ts new file mode 100644 index 00000000..ed2ab6d0 --- /dev/null +++ b/shellcomponent/src/shims/fs-empty.ts @@ -0,0 +1,13 @@ +/** Browser stub: KeeperSdk SessionManager pulls `fs` for FileConfigLoader; embedded shell uses in-memory config only. */ +const notAvailable = (): never => { + throw new Error("Node fs is not available in keeper-shell (use in-memory session storage)."); +}; + +export default { + promises: { + readFile: notAvailable, + writeFile: notAvailable, + mkdir: notAvailable, + access: notAvailable, + }, +}; diff --git a/shellcomponent/src/shims/fs-promises-empty.ts b/shellcomponent/src/shims/fs-promises-empty.ts new file mode 100644 index 00000000..4e750185 --- /dev/null +++ b/shellcomponent/src/shims/fs-promises-empty.ts @@ -0,0 +1,21 @@ +/** + * Browser stub for `import fs from "fs/promises"` (KeeperSdk SessionManager / FileConfigLoader). + * The embedded shell uses {@link InMemoryConfigLoader} only; these paths are not used at runtime. + */ +const notAvailable = (): never => { + throw new Error("fs/promises is not available in keeper-shell."); +}; + +const stub = { + readFile: notAvailable, + writeFile: notAvailable, + mkdir: notAvailable, + access: notAvailable, +}; + +export default stub; + +export const readFile = notAvailable; +export const writeFile = notAvailable; +export const mkdir = notAvailable; +export const access = notAvailable; diff --git a/shellcomponent/src/shims/os-homedir.ts b/shellcomponent/src/shims/os-homedir.ts new file mode 100644 index 00000000..38f82184 --- /dev/null +++ b/shellcomponent/src/shims/os-homedir.ts @@ -0,0 +1,6 @@ +/** Minimal `os` stub for bundles that never instantiate FileConfigLoader with default paths. */ +export function homedir(): string { + return "/"; +} + +export default { homedir }; diff --git a/shellcomponent/src/vite-env.d.ts b/shellcomponent/src/vite-env.d.ts new file mode 100644 index 00000000..8305b480 --- /dev/null +++ b/shellcomponent/src/vite-env.d.ts @@ -0,0 +1,14 @@ +/// + +interface ImportMetaEnv { + readonly VITE_KEEPER_DEV_DEVICE_USER?: string; + readonly VITE_KEEPER_DEV_DEVICE_TOKEN?: string; + readonly VITE_KEEPER_DEV_DEVICE_PRIVATE_KEY?: string; + readonly VITE_KEEPER_USERNAME?: string; + readonly VITE_KEEPER_PASSWORD?: string; + readonly VITE_KEEPER_SESSION_TOKEN?: string; + readonly VITE_KEEPER_HOST?: string; + readonly VITE_REGISTER_DEVICE_TOKEN?: string; + readonly VITE_REGISTER_DEVICE_PRIVATE_KEY?: string; + readonly VITE_RESTORE_SESSION_JSON?: string; +} diff --git a/shellcomponent/tsconfig.json b/shellcomponent/tsconfig.json new file mode 100644 index 00000000..c088d75a --- /dev/null +++ b/shellcomponent/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2017", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["DOM", "ESNext"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "resolveJsonModule": true, + "types": ["node"], + "baseUrl": ".", + "paths": { + "@keeper-security/keeper-sdk-javascript": ["../KeeperSdk/src/browser.ts"] + } + }, + "include": ["src/**/*.ts", "vite.config.ts"] +} diff --git a/shellcomponent/vite.config.ts b/shellcomponent/vite.config.ts new file mode 100644 index 00000000..eb651df7 --- /dev/null +++ b/shellcomponent/vite.config.ts @@ -0,0 +1,98 @@ +import { readFileSync, existsSync } from "node:fs"; +import { defineConfig, type Plugin } from "vite"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { keeperSameOriginProxyPlugin } from "./vite/keeperSameOriginProxyPlugin.js"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const repoConfJson = resolve(__dirname, "../conf.json"); + +/** Dev-only: load repo-root conf.json without typing a full /@fs path in the shell. */ +function keeperDevSessionConfPlugin(): Plugin { + return { + name: "keeper-dev-session-conf", + configureServer(server) { + server.middlewares.use("/dev/keeper-session.json", (_req, res, next) => { + try { + if (!existsSync(repoConfJson)) { + res.statusCode = 404; + res.end("conf.json not found at repo root"); + return; + } + res.setHeader("Content-Type", "application/json"); + res.end(readFileSync(repoConfJson)); + } catch (e) { + next(e); + } + }); + }, + }; +} + +export default defineConfig(({ command, mode }) => { + const isServe = command === "serve"; + const isProd = mode === "production"; + + return { + root: ".", + plugins: isServe ? [keeperDevSessionConfPlugin(), keeperSameOriginProxyPlugin()] : [], + resolve: { + alias: { + // Bundle from KeeperSdk sources (CJS dist/browser.js breaks Rollup named exports). + "@keeper-security/keeper-sdk-javascript": resolve( + __dirname, + "../KeeperSdk/src/browser.ts" + ), + "fs/promises": resolve(__dirname, "src/shims/fs-promises-empty.ts"), + fs: resolve(__dirname, "src/shims/fs-empty.ts"), + path: resolve(__dirname, "node_modules/path-browserify/index.js"), + os: resolve(__dirname, "src/shims/os-homedir.ts"), + buffer: "buffer/", + }, + }, + define: { + global: "globalThis", + }, + optimizeDeps: { + include: [ + "@keeper-security/keeper-sdk-javascript", + "@keeper-security/keeperapi", + "@xterm/xterm", + "@xterm/addon-fit", + "buffer", + "protobufjs", + ], + esbuildOptions: { + define: { + global: "globalThis", + }, + }, + }, + build: { + target: "es2017", + sourcemap: isProd ? false : true, + commonjsOptions: { + transformMixedEsModules: true, + }, + lib: { + entry: resolve(__dirname, "src/index.ts"), + name: "KeeperShell", + formats: ["es", "umd"], + fileName: (format) => + format === "umd" ? "keeper-shell.umd.cjs" : "keeper-shell.es.js", + }, + rollupOptions: { + output: { + exports: "named", + assetFileNames: "keeper-shell.[ext]", + }, + }, + }, + server: { + port: 5175, + fs: { + allow: [__dirname, resolve(__dirname, "..")], + }, + }, + }; +}); diff --git a/shellcomponent/vite/keeperSameOriginProxyPlugin.ts b/shellcomponent/vite/keeperSameOriginProxyPlugin.ts new file mode 100644 index 00000000..73fe160b --- /dev/null +++ b/shellcomponent/vite/keeperSameOriginProxyPlugin.ts @@ -0,0 +1,54 @@ +import type { Plugin } from "vite"; +import httpProxy from "http-proxy"; +import { isAllowedKeeperProxyHost } from "../src/dev/keeperProxyHosts.js"; + +/** Dev: proxy `/__keeper//…` (HTTPS) and `/__keeper-wss//…` (WSS) to Keeper. */ +export function keeperSameOriginProxyPlugin(): Plugin { + const proxy = httpProxy.createProxyServer({ + changeOrigin: true, + secure: true, + ws: true, + }); + + return { + name: "keeper-same-origin-proxy", + configureServer(server) { + server.middlewares.use((req, res, next) => { + const raw = req.url ?? ""; + const match = raw.match(/^\/__keeper\/([^/]+)(\/.*)?$/); + if (!match) { + next(); + return; + } + const targetHost = match[1]; + if (!isAllowedKeeperProxyHost(targetHost)) { + res.statusCode = 403; + res.end("Forbidden keeper proxy host"); + return; + } + req.url = match[2] ?? "/"; + proxy.web( + req, + res, + { target: `https://${targetHost}` }, + (err) => { + if (err) next(err); + } + ); + }); + + server.httpServer?.on("upgrade", (req, socket, head) => { + const raw = req.url ?? ""; + const match = raw.match(/^\/__keeper-wss\/([^/]+)(\/.*)?$/); + if (!match) return; + const targetHost = match[1]; + if (!isAllowedKeeperProxyHost(targetHost)) { + socket.destroy(); + return; + } + req.url = match[2] ?? "/"; + proxy.ws(req, socket, head, { target: `wss://${targetHost}` }); + }); + }, + }; +}