diff --git a/api/package-lock.json b/api/package-lock.json index a376b0b05..83212d802 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -68,6 +68,7 @@ "mustache": "^4.2.0", "nest-access-control": "^3.1.0", "nest-authz": "^2.11.0", + "nest-commander": "^3.15.0", "nestjs-pino": "^4.1.0", "node-cache": "^5.1.2", "node-window-polyfill": "^1.0.2", @@ -87,7 +88,6 @@ "stoppable": "^1.1.0", "strftime": "^0.10.3", "systeminformation": "^5.23.5", - "ts-command-line-args": "^2.5.1", "uuid": "^11.0.2", "ws": "^8.18.0", "xhr2": "^0.2.1", @@ -2205,6 +2205,19 @@ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "license": "MIT" }, + "node_modules/@golevelup/nestjs-discovery": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@golevelup/nestjs-discovery/-/nestjs-discovery-4.0.1.tgz", + "integrity": "sha512-HFXBJayEkYcU/bbxOztozONdWaZR34ZeJ2zRbZIWY8d5K26oPZQTvJ4L0STW3XVRGWtoE0WBpmx2YPNgYvcmJQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "@nestjs/common": "^10.x", + "@nestjs/core": "^10.x" + } + }, "node_modules/@graphql-codegen/add": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-5.0.3.tgz", @@ -5100,6 +5113,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/inquirer": { + "version": "8.2.10", + "resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-8.2.10.tgz", + "integrity": "sha512-IdD5NmHyVjWM8SHWo/kPBgtzXatwPkfwzyP3fN1jF2g9BWt5WO+8hL2F4o2GKIYsU40PpqeevuUWvkS/roXJkA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/through": "*", + "rxjs": "^7.2.0" + } + }, "node_modules/@types/ip": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@types/ip/-/ip-1.1.3.tgz", @@ -5294,6 +5318,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/through": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.33.tgz", + "integrity": "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -5956,7 +5990,6 @@ }, "node_modules/ansi-escapes": { "version": "4.3.2", - "dev": true, "license": "MIT", "dependencies": { "type-fest": "^0.21.3" @@ -5970,7 +6003,6 @@ }, "node_modules/ansi-escapes/node_modules/type-fest": { "version": "0.21.3", - "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -6027,13 +6059,6 @@ "version": "2.0.1", "license": "Python-2.0" }, - "node_modules/array-back": { - "version": "3.1.0", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -6547,7 +6572,6 @@ }, "node_modules/callsites": { "version": "3.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6753,7 +6777,6 @@ }, "node_modules/chardet": { "version": "0.7.0", - "dev": true, "license": "MIT" }, "node_modules/charm": { @@ -6818,7 +6841,6 @@ }, "node_modules/cli-cursor": { "version": "3.1.0", - "dev": true, "license": "MIT", "dependencies": { "restore-cursor": "^3.1.0" @@ -6829,7 +6851,6 @@ }, "node_modules/cli-spinners": { "version": "2.8.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6887,7 +6908,6 @@ }, "node_modules/cli-width": { "version": "3.0.0", - "dev": true, "license": "ISC", "engines": { "node": ">= 10" @@ -6959,103 +6979,6 @@ "version": "1.2.9", "license": "MIT" }, - "node_modules/command-line-args": { - "version": "5.2.1", - "license": "MIT", - "dependencies": { - "array-back": "^3.1.0", - "find-replace": "^3.0.0", - "lodash.camelcase": "^4.3.0", - "typical": "^4.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/command-line-usage": { - "version": "6.1.3", - "license": "MIT", - "dependencies": { - "array-back": "^4.0.2", - "chalk": "^2.4.2", - "table-layout": "^1.0.2", - "typical": "^5.2.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/command-line-usage/node_modules/ansi-styles": { - "version": "3.2.1", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/command-line-usage/node_modules/array-back": { - "version": "4.0.2", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/command-line-usage/node_modules/chalk": { - "version": "2.4.2", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/command-line-usage/node_modules/color-convert": { - "version": "1.9.3", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/command-line-usage/node_modules/color-name": { - "version": "1.1.3", - "license": "MIT" - }, - "node_modules/command-line-usage/node_modules/escape-string-regexp": { - "version": "1.0.5", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/command-line-usage/node_modules/has-flag": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/command-line-usage/node_modules/supports-color": { - "version": "5.5.0", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/command-line-usage/node_modules/typical": { - "version": "5.2.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/commander": { "version": "2.15.1", "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", @@ -7638,7 +7561,6 @@ "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", - "dev": true, "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", @@ -7678,7 +7600,6 @@ }, "node_modules/cosmiconfig/node_modules/path-type": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7973,13 +7894,6 @@ "node": ">=6" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "dev": true, @@ -7997,7 +7911,6 @@ }, "node_modules/defaults": { "version": "1.0.4", - "dev": true, "license": "MIT", "dependencies": { "clone": "^1.0.2" @@ -8008,7 +7921,6 @@ }, "node_modules/defaults/node_modules/clone": { "version": "1.0.4", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8" @@ -8379,7 +8291,6 @@ }, "node_modules/error-ex": { "version": "1.3.2", - "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -9291,7 +9202,6 @@ }, "node_modules/external-editor": { "version": "3.1.0", - "dev": true, "license": "MIT", "dependencies": { "chardet": "^0.7.0", @@ -9533,7 +9443,6 @@ }, "node_modules/figures": { "version": "3.2.0", - "dev": true, "license": "MIT", "dependencies": { "escape-string-regexp": "^1.0.5" @@ -9547,7 +9456,6 @@ }, "node_modules/figures/node_modules/escape-string-regexp": { "version": "1.0.5", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.0" @@ -9660,16 +9568,6 @@ "merge": "^2.1.1" } }, - "node_modules/find-replace": { - "version": "3.0.0", - "license": "MIT", - "dependencies": { - "array-back": "^3.0.1" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/find-root": { "version": "1.1.0", "dev": true, @@ -10808,7 +10706,6 @@ }, "node_modules/import-fresh": { "version": "3.3.0", - "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -10823,7 +10720,6 @@ }, "node_modules/import-fresh/node_modules/resolve-from": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -10966,7 +10862,6 @@ }, "node_modules/is-arrayish": { "version": "0.2.1", - "dev": true, "license": "MIT" }, "node_modules/is-binary-path": { @@ -11018,7 +10913,6 @@ }, "node_modules/is-interactive": { "version": "1.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11121,7 +11015,6 @@ }, "node_modules/is-unicode-supported": { "version": "0.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -11318,7 +11211,6 @@ }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", - "dev": true, "license": "MIT" }, "node_modules/json-schema": { @@ -11489,7 +11381,6 @@ }, "node_modules/lines-and-columns": { "version": "1.2.4", - "dev": true, "license": "MIT" }, "node_modules/listr2": { @@ -11593,10 +11484,6 @@ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "license": "MIT" - }, "node_modules/lodash.ismatch": { "version": "4.4.0", "dev": true, @@ -11641,7 +11528,6 @@ }, "node_modules/log-symbols": { "version": "4.1.0", - "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -12316,6 +12202,85 @@ "rxjs": "^7.5.6" } }, + "node_modules/nest-commander": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/nest-commander/-/nest-commander-3.15.0.tgz", + "integrity": "sha512-o9VEfFj/w2nm+hQi6fnkxL1GAFZW/KmuGcIE7/B/TX0gwm0QVy8svAF75EQm8wrDjcvWS7Cx/ArnkFn2C+iM2w==", + "license": "MIT", + "dependencies": { + "@fig/complete-commander": "^3.0.0", + "@golevelup/nestjs-discovery": "4.0.1", + "commander": "11.1.0", + "cosmiconfig": "8.3.6", + "inquirer": "8.2.6" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@types/inquirer": "^8.1.3" + } + }, + "node_modules/nest-commander/node_modules/@fig/complete-commander": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fig/complete-commander/-/complete-commander-3.2.0.tgz", + "integrity": "sha512-1Holl3XtRiANVKURZwgpjCnPuV4RsHp+XC0MhgvyAX/avQwj7F2HUItYOvGi/bXjJCkEzgBZmVfCr0HBA+q+Bw==", + "license": "MIT", + "dependencies": { + "prettier": "^3.2.5" + }, + "peerDependencies": { + "commander": "^11.1.0" + } + }, + "node_modules/nest-commander/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/nest-commander/node_modules/inquirer": { + "version": "8.2.6", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", + "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/nest-commander/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/nestjs-pino": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.1.0.tgz", @@ -12734,7 +12699,6 @@ }, "node_modules/ora": { "version": "5.4.1", - "dev": true, "license": "MIT", "dependencies": { "bl": "^4.1.0", @@ -12756,7 +12720,6 @@ }, "node_modules/os-tmpdir": { "version": "1.0.2", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12886,7 +12849,6 @@ }, "node_modules/parent-module": { "version": "1.0.1", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -12909,7 +12871,6 @@ }, "node_modules/parse-json": { "version": "5.2.0", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -13501,9 +13462,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", - "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13868,13 +13827,6 @@ "node": ">=8" } }, - "node_modules/reduce-flatten": { - "version": "2.0.0", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -14108,7 +14060,6 @@ }, "node_modules/restore-cursor": { "version": "3.1.0", - "dev": true, "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -14120,7 +14071,6 @@ }, "node_modules/restore-cursor/node_modules/mimic-fn": { "version": "2.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -14128,7 +14078,6 @@ }, "node_modules/restore-cursor/node_modules/onetime": { "version": "5.1.2", - "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -14251,7 +14200,6 @@ }, "node_modules/run-async": { "version": "2.4.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -14995,10 +14943,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string-format": { - "version": "2.0.0", - "license": "WTFPL OR MIT" - }, "node_modules/string-width": { "version": "4.2.3", "license": "MIT", @@ -15198,33 +15142,6 @@ "url": "https://www.buymeacoffee.com/systeminfo" } }, - "node_modules/table-layout": { - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "array-back": "^4.0.1", - "deep-extend": "~0.6.0", - "typical": "^5.2.0", - "wordwrapjs": "^4.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/table-layout/node_modules/array-back": { - "version": "4.0.2", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/table-layout/node_modules/typical": { - "version": "5.2.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/tar-fs": { "version": "2.0.1", "license": "MIT", @@ -15345,7 +15262,6 @@ }, "node_modules/through": { "version": "2.3.8", - "dev": true, "license": "MIT" }, "node_modules/through2": { @@ -15451,7 +15367,6 @@ }, "node_modules/tmp": { "version": "0.0.33", - "dev": true, "license": "MIT", "dependencies": { "os-tmpdir": "~1.0.2" @@ -15550,19 +15465,6 @@ "typescript": ">=4.2.0" } }, - "node_modules/ts-command-line-args": { - "version": "2.5.1", - "license": "ISC", - "dependencies": { - "chalk": "^4.1.0", - "command-line-args": "^5.1.1", - "command-line-usage": "^6.1.0", - "string-format": "^2.0.0" - }, - "bin": { - "write-markdown": "dist/write-markdown.js" - } - }, "node_modules/ts-invariant": { "version": "0.10.3", "license": "MIT", @@ -15732,7 +15634,7 @@ "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, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15765,13 +15667,6 @@ } } }, - "node_modules/typical": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ua-parser-js": { "version": "1.0.37", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz", @@ -16422,7 +16317,6 @@ }, "node_modules/wcwidth": { "version": "1.0.1", - "dev": true, "license": "MIT", "dependencies": { "defaults": "^1.0.3" @@ -16503,24 +16397,6 @@ "dev": true, "license": "MIT" }, - "node_modules/wordwrapjs": { - "version": "4.0.1", - "license": "MIT", - "dependencies": { - "reduce-flatten": "^2.0.0", - "typical": "^5.2.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/wordwrapjs/node_modules/typical": { - "version": "5.2.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "dev": true, @@ -18069,6 +17945,14 @@ } } }, + "@golevelup/nestjs-discovery": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@golevelup/nestjs-discovery/-/nestjs-discovery-4.0.1.tgz", + "integrity": "sha512-HFXBJayEkYcU/bbxOztozONdWaZR34ZeJ2zRbZIWY8d5K26oPZQTvJ4L0STW3XVRGWtoE0WBpmx2YPNgYvcmJQ==", + "requires": { + "lodash": "^4.17.21" + } + }, "@graphql-codegen/add": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-5.0.3.tgz", @@ -19983,6 +19867,16 @@ "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", "dev": true }, + "@types/inquirer": { + "version": "8.2.10", + "resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-8.2.10.tgz", + "integrity": "sha512-IdD5NmHyVjWM8SHWo/kPBgtzXatwPkfwzyP3fN1jF2g9BWt5WO+8hL2F4o2GKIYsU40PpqeevuUWvkS/roXJkA==", + "peer": true, + "requires": { + "@types/through": "*", + "rxjs": "^7.2.0" + } + }, "@types/ip": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@types/ip/-/ip-1.1.3.tgz", @@ -20166,6 +20060,15 @@ "integrity": "sha512-QIvDlGAKyF3YJbT3QZnfC+RIvV5noyDbi+ZJ5rkaSRqxCGrYJefgXm3leZAjtoQOutZe1hCXbAg+p89/Vj4HlQ==", "dev": true }, + "@types/through": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.33.tgz", + "integrity": "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==", + "peer": true, + "requires": { + "@types/node": "*" + } + }, "@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -20600,14 +20503,12 @@ }, "ansi-escapes": { "version": "4.3.2", - "dev": true, "requires": { "type-fest": "^0.21.3" }, "dependencies": { "type-fest": { - "version": "0.21.3", - "dev": true + "version": "0.21.3" } } }, @@ -20642,9 +20543,6 @@ "argparse": { "version": "2.0.1" }, - "array-back": { - "version": "3.1.0" - }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -20985,8 +20883,7 @@ } }, "callsites": { - "version": "3.1.0", - "dev": true + "version": "3.1.0" }, "camel-case": { "version": "4.1.2", @@ -21125,8 +21022,7 @@ } }, "chardet": { - "version": "0.7.0", - "dev": true + "version": "0.7.0" }, "charm": { "version": "0.1.2", @@ -21175,14 +21071,12 @@ }, "cli-cursor": { "version": "3.1.0", - "dev": true, "requires": { "restore-cursor": "^3.1.0" } }, "cli-spinners": { - "version": "2.8.0", - "dev": true + "version": "2.8.0" }, "cli-table": { "version": "0.3.11", @@ -21218,8 +21112,7 @@ } }, "cli-width": { - "version": "3.0.0", - "dev": true + "version": "3.0.0" }, "cliui": { "version": "8.0.1", @@ -21264,67 +21157,6 @@ "command-exists": { "version": "1.2.9" }, - "command-line-args": { - "version": "5.2.1", - "requires": { - "array-back": "^3.1.0", - "find-replace": "^3.0.0", - "lodash.camelcase": "^4.3.0", - "typical": "^4.0.0" - } - }, - "command-line-usage": { - "version": "6.1.3", - "requires": { - "array-back": "^4.0.2", - "chalk": "^2.4.2", - "table-layout": "^1.0.2", - "typical": "^5.2.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "requires": { - "color-convert": "^1.9.0" - } - }, - "array-back": { - "version": "4.0.2" - }, - "chalk": { - "version": "2.4.2", - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3" - }, - "escape-string-regexp": { - "version": "1.0.5" - }, - "has-flag": { - "version": "3.0.0" - }, - "supports-color": { - "version": "5.5.0", - "requires": { - "has-flag": "^3.0.0" - } - }, - "typical": { - "version": "5.2.0" - } - } - }, "commander": { "version": "2.15.1", "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", @@ -21738,7 +21570,6 @@ "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", - "dev": true, "requires": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", @@ -21747,8 +21578,7 @@ }, "dependencies": { "path-type": { - "version": "4.0.0", - "dev": true + "version": "4.0.0" } } }, @@ -21956,9 +21786,6 @@ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true }, - "deep-extend": { - "version": "0.6.0" - }, "deep-is": { "version": "0.1.4", "dev": true @@ -21971,14 +21798,12 @@ }, "defaults": { "version": "1.0.4", - "dev": true, "requires": { "clone": "^1.0.2" }, "dependencies": { "clone": { - "version": "1.0.4", - "dev": true + "version": "1.0.4" } } }, @@ -22223,7 +22048,6 @@ }, "error-ex": { "version": "1.3.2", - "dev": true, "requires": { "is-arrayish": "^0.2.1" } @@ -22752,7 +22576,6 @@ }, "external-editor": { "version": "3.1.0", - "dev": true, "requires": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", @@ -22945,14 +22768,12 @@ }, "figures": { "version": "3.2.0", - "dev": true, "requires": { "escape-string-regexp": "^1.0.5" }, "dependencies": { "escape-string-regexp": { - "version": "1.0.5", - "dev": true + "version": "1.0.5" } } }, @@ -23035,12 +22856,6 @@ "merge": "^2.1.1" } }, - "find-replace": { - "version": "3.0.0", - "requires": { - "array-back": "^3.0.1" - } - }, "find-root": { "version": "1.1.0", "dev": true @@ -23809,15 +23624,13 @@ }, "import-fresh": { "version": "3.3.0", - "dev": true, "requires": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" }, "dependencies": { "resolve-from": { - "version": "4.0.0", - "dev": true + "version": "4.0.0" } } }, @@ -23918,8 +23731,7 @@ } }, "is-arrayish": { - "version": "0.2.1", - "dev": true + "version": "0.2.1" }, "is-binary-path": { "version": "2.1.0", @@ -23950,8 +23762,7 @@ } }, "is-interactive": { - "version": "1.0.0", - "dev": true + "version": "1.0.0" }, "is-lower-case": { "version": "2.0.2", @@ -24009,8 +23820,7 @@ } }, "is-unicode-supported": { - "version": "0.1.0", - "dev": true + "version": "0.1.0" }, "is-upper-case": { "version": "2.0.2", @@ -24141,8 +23951,7 @@ "dev": true }, "json-parse-even-better-errors": { - "version": "2.3.1", - "dev": true + "version": "2.3.1" }, "json-schema": { "version": "0.4.0" @@ -24260,8 +24069,7 @@ } }, "lines-and-columns": { - "version": "1.2.4", - "dev": true + "version": "1.2.4" }, "listr2": { "version": "4.0.5", @@ -24326,9 +24134,6 @@ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, - "lodash.camelcase": { - "version": "4.3.0" - }, "lodash.ismatch": { "version": "4.4.0", "dev": true @@ -24366,7 +24171,6 @@ }, "log-symbols": { "version": "4.1.0", - "dev": true, "requires": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" @@ -24776,6 +24580,65 @@ "casbin": "^5.30.0" } }, + "nest-commander": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/nest-commander/-/nest-commander-3.15.0.tgz", + "integrity": "sha512-o9VEfFj/w2nm+hQi6fnkxL1GAFZW/KmuGcIE7/B/TX0gwm0QVy8svAF75EQm8wrDjcvWS7Cx/ArnkFn2C+iM2w==", + "requires": { + "@fig/complete-commander": "^3.0.0", + "@golevelup/nestjs-discovery": "4.0.1", + "commander": "11.1.0", + "cosmiconfig": "8.3.6", + "inquirer": "8.2.6" + }, + "dependencies": { + "@fig/complete-commander": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fig/complete-commander/-/complete-commander-3.2.0.tgz", + "integrity": "sha512-1Holl3XtRiANVKURZwgpjCnPuV4RsHp+XC0MhgvyAX/avQwj7F2HUItYOvGi/bXjJCkEzgBZmVfCr0HBA+q+Bw==", + "requires": { + "prettier": "^3.2.5" + } + }, + "commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==" + }, + "inquirer": { + "version": "8.2.6", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", + "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", + "requires": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + } + }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + } + } + }, "nestjs-pino": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.1.0.tgz", @@ -25060,7 +24923,6 @@ }, "ora": { "version": "5.4.1", - "dev": true, "requires": { "bl": "^4.1.0", "chalk": "^4.1.0", @@ -25074,8 +24936,7 @@ } }, "os-tmpdir": { - "version": "1.0.2", - "dev": true + "version": "1.0.2" }, "p-cancelable": { "version": "4.0.1", @@ -25160,7 +25021,6 @@ }, "parent-module": { "version": "1.0.1", - "dev": true, "requires": { "callsites": "^3.0.0" } @@ -25175,7 +25035,6 @@ }, "parse-json": { "version": "5.2.0", - "dev": true, "requires": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -25596,9 +25455,7 @@ "prettier": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", - "dev": true, - "peer": true + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==" }, "pretty-ms": { "version": "9.2.0", @@ -25831,9 +25688,6 @@ "strip-indent": "^3.0.0" } }, - "reduce-flatten": { - "version": "2.0.0" - }, "redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -25993,19 +25847,16 @@ }, "restore-cursor": { "version": "3.1.0", - "dev": true, "requires": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" }, "dependencies": { "mimic-fn": { - "version": "2.1.0", - "dev": true + "version": "2.1.0" }, "onetime": { "version": "5.1.2", - "dev": true, "requires": { "mimic-fn": "^2.1.0" } @@ -26082,8 +25933,7 @@ "requires": {} }, "run-async": { - "version": "2.4.1", - "dev": true + "version": "2.4.1" }, "run-parallel": { "version": "1.2.0", @@ -26599,9 +26449,6 @@ "integrity": "sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==", "dev": true }, - "string-format": { - "version": "2.0.0" - }, "string-width": { "version": "4.2.3", "requires": { @@ -26704,23 +26551,6 @@ "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.23.5.tgz", "integrity": "sha512-PEpJwhRYxZgBCAlWZhWIgfMTjXLqfcaZ1pJsJn9snWNfBW/Z1YQg1mbIUSWrEV3ErAHF7l/OoVLQeaZDlPzkpA==" }, - "table-layout": { - "version": "1.0.2", - "requires": { - "array-back": "^4.0.1", - "deep-extend": "~0.6.0", - "typical": "^5.2.0", - "wordwrapjs": "^4.0.0" - }, - "dependencies": { - "array-back": { - "version": "4.0.2" - }, - "typical": { - "version": "5.2.0" - } - } - }, "tar-fs": { "version": "2.0.1", "requires": { @@ -26812,8 +26642,7 @@ } }, "through": { - "version": "2.3.8", - "dev": true + "version": "2.3.8" }, "through2": { "version": "4.0.2", @@ -26885,7 +26714,6 @@ }, "tmp": { "version": "0.0.33", - "dev": true, "requires": { "os-tmpdir": "~1.0.2" } @@ -26944,15 +26772,6 @@ "dev": true, "requires": {} }, - "ts-command-line-args": { - "version": "2.5.1", - "requires": { - "chalk": "^4.1.0", - "command-line-args": "^5.1.1", - "command-line-usage": "^6.1.0", - "string-format": "^2.0.0" - } - }, "ts-invariant": { "version": "0.10.3", "requires": { @@ -27058,7 +26877,7 @@ "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 + "devOptional": true }, "typescript-eslint": { "version": "8.13.0", @@ -27071,9 +26890,6 @@ "@typescript-eslint/utils": "8.13.0" } }, - "typical": { - "version": "4.0.0" - }, "ua-parser-js": { "version": "1.0.37", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz", @@ -27445,7 +27261,6 @@ }, "wcwidth": { "version": "1.0.1", - "dev": true, "requires": { "defaults": "^1.0.3" } @@ -27500,18 +27315,6 @@ "version": "1.0.0", "dev": true }, - "wordwrapjs": { - "version": "4.0.1", - "requires": { - "reduce-flatten": "^2.0.0", - "typical": "^5.2.0" - }, - "dependencies": { - "typical": { - "version": "5.2.0" - } - } - }, "wrap-ansi": { "version": "7.0.0", "dev": true, diff --git a/api/package.json b/api/package.json index 1a671115b..fff6c79ce 100644 --- a/api/package.json +++ b/api/package.json @@ -101,6 +101,7 @@ "mustache": "^4.2.0", "nest-access-control": "^3.1.0", "nest-authz": "^2.11.0", + "nest-commander": "^3.15.0", "nestjs-pino": "^4.1.0", "node-cache": "^5.1.2", "node-window-polyfill": "^1.0.2", @@ -120,7 +121,6 @@ "stoppable": "^1.1.0", "strftime": "^0.10.3", "systeminformation": "^5.23.5", - "ts-command-line-args": "^2.5.1", "uuid": "^11.0.2", "ws": "^8.18.0", "xhr2": "^0.2.1", diff --git a/api/src/cli.ts b/api/src/cli.ts index fc2d612c1..2903f8403 100644 --- a/api/src/cli.ts +++ b/api/src/cli.ts @@ -1,13 +1,26 @@ #!/usr/bin/env node import '@app/dotenv'; -import { main } from '@app/cli/index'; -import { internalLogger } from '@app/core/log'; +import { execSync } from 'child_process'; + +import { CommandFactory } from 'nest-commander'; + +import { cliLogger, internalLogger } from '@app/core/log'; +import { CliModule } from '@app/unraid-api/cli/cli.module'; try { - await main(); + const shellToUse = execSync('which bash'); + await CommandFactory.run(CliModule, { + cliName: 'unraid-api', + logger: false, + completion: { + fig: true, + cmd: 'unraid-api', + nativeShell: { executablePath: shellToUse.toString('utf-8') }, + }, + }); } catch (error) { - console.log(error); + cliLogger.error('ERROR:', error); internalLogger.error({ message: 'Failed to start unraid-api', error, diff --git a/api/src/cli/commands/key.ts b/api/src/cli/commands/key.ts deleted file mode 100644 index 5af096de9..000000000 --- a/api/src/cli/commands/key.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { ArgumentConfig, parse } from 'ts-command-line-args'; - -import { cliLogger } from '@app/core/log'; -import { Role } from '@app/graphql/generated/api/types'; -import { ApiKeyService } from '@app/unraid-api/auth/api-key.service'; - -enum Command { - Get = 'get', - Create = 'create', -} - -type KeyFlags = { - create?: boolean; - command: string; - description?: string; - name: string; - permissions?: string; - roles?: string; -}; - -const validRoles: Set = new Set(Object.values(Role)); - -const validateRoles = (rolesStr?: string): Role[] => { - if (!rolesStr) return [Role.GUEST]; - - const requestedRoles = rolesStr.split(',').map((role) => role.trim().toUpperCase() as Role); - const validRequestedRoles = requestedRoles.filter((role) => validRoles.has(role)); - - if (validRequestedRoles.length === 0) { - throw new Error(`Invalid roles. Valid options are: ${Array.from(validRoles).join(', ')}`); - } - - const invalidRoles = requestedRoles.filter((role) => !validRoles.has(role)); - - if (invalidRoles.length > 0) { - cliLogger.warn(`Ignoring invalid roles: ${invalidRoles.join(', ')}`); - } - - return validRequestedRoles; -}; - -const keyOptions: ArgumentConfig = { - command: { type: String, description: 'get or create' }, - name: { type: String, description: 'Name of the API key', typeLabel: '{underline name}' }, - create: { type: Boolean, optional: true, description: "Create the key if it doesn't exist" }, - description: { type: String, optional: true, description: 'Description of the API key' }, - roles: { - type: String, - optional: true, - description: `Comma-separated list of roles (${Object.values(Role).join(', ')})`, - typeLabel: '{underline role1,role2}', - }, - permissions: { - type: String, - optional: true, - description: 'Comma-separated list of permissions', - typeLabel: '{underline perm1,perm2}', - }, -}; - -export const key = async (...argv: string[]) => { - try { - const options = parse(keyOptions, { argv }); - const apiKeyService = new ApiKeyService(); - - if (!options.name) { - throw new Error('Name is required'); - } - - switch (options.command) { - case Command.Create: { - const roles = validateRoles(options.roles); - const key = await apiKeyService.create( - options.name, - options.description || `CLI generated key: ${options.name}`, - roles, - true - ); - - cliLogger.info('API Key: ', key); - cliLogger.info('API key created successfully'); - - break; - } - - case Command.Get: { - const key = await apiKeyService.findByField('name', options.name); - - if (!key && options.create) { - const roles = validateRoles(options.roles); - const newKey = await apiKeyService.create( - options.name, - options.description || `CLI generated key: ${options.name}`, - roles, - true - ); - - cliLogger.info('New API Key: ', newKey); - cliLogger.info('API key created successfully'); - } else if (key) { - cliLogger.info('API Key: ', key); - } else { - throw new Error(`No API key found with name: ${options.name}`); - } - - break; - } - - default: - throw new Error(`Invalid command. Use: ${Object.values(Command).join(' or ')}`); - } - } catch (error) { - if (error instanceof Error) { - cliLogger.error(`Failed to process API key: ${error.message}`); - } - - process.exit(1); - } -}; diff --git a/api/src/cli/commands/report.ts b/api/src/cli/commands/report.ts deleted file mode 100644 index 13e32e964..000000000 --- a/api/src/cli/commands/report.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { stdout } from 'process'; -import readLine from 'readline'; - -import { ApolloClient, ApolloQueryResult, NormalizedCacheObject } from '@apollo/client/core/index.js'; -import ipRegex from 'ip-regex'; - -import { setEnv } from '@app/cli/set-env'; -import { cliLogger } from '@app/core/log'; -import { isUnraidApiRunning } from '@app/core/utils/pm2/unraid-api-running'; -import { API_VERSION } from '@app/environment'; -import { MinigraphStatus } from '@app/graphql/generated/api/types'; -import { getters, store } from '@app/store'; -import { loadConfigFile } from '@app/store/modules/config'; -import { loadStateFiles } from '@app/store/modules/emhttp'; - -import type { getCloudQuery, getServersQuery } from '../../graphql/generated/api/operations'; -import { getApiApolloClient } from '../../graphql/client/api/get-api-client'; -import { getCloudDocument, getServersDocument } from '../../graphql/generated/api/operations'; - -type CloudQueryResult = NonNullable['data']['cloud']>; -type ServersQueryResultServer = NonNullable['data']['servers']>[0]; - -type Verbosity = '' | '-v' | '-vv'; - -type ServersPayload = { - online: ServersQueryResultServer[]; - offline: ServersQueryResultServer[]; - invalid: ServersQueryResultServer[]; -}; - -type ReportObject = { - os: { - serverName: string; - version: string; - }; - api: { - version: string; - status: 'running' | 'stopped'; - environment: string; - nodeVersion: string; - }; - apiKey: 'valid' | 'invalid' | string; - servers?: ServersPayload | null; - myServers: { - status: 'authenticated' | 'signed out'; - myServersUsername?: string; - }; - minigraph: { - status: MinigraphStatus; - timeout: number | null; - error: string | null; - }; - cloud: { - status: string; - error?: string; - ip?: string; - allowedOrigins?: string[] | null; - }; -}; - -// This should return the status of the apiKey and mothership -export const getCloudData = async ( - client: ApolloClient -): Promise => { - try { - const cloud = await client.query({ query: getCloudDocument }); - return cloud.data.cloud ?? null; - } catch (error: unknown) { - cliLogger.trace( - 'Failed fetching cloud from local graphql with "%s"', - error instanceof Error ? error.message : 'Unknown Error' - ); - - return null; - } -}; - -export const getServersData = async ({ - client, - v, -}: { - client: ApolloClient; - v: Verbosity; -}): Promise => { - if (v === '') { - return null; - } - - try { - const servers = await client.query({ query: getServersDocument }); - const foundServers = servers.data.servers.reduce( - (acc, curr) => { - switch (curr.status) { - case 'online': - acc.online.push(curr); - break; - case 'offline': - acc.offline.push(curr); - break; - default: - acc.invalid.push(curr); - break; - } - - return acc; - }, - { online: [], offline: [], invalid: [] } - ); - return foundServers; - } catch (error: unknown) { - cliLogger.trace( - 'Failed fetching servers from local graphql with "%s"', - error instanceof Error ? error.message : 'Unknown Error' - ); - return { - online: [], - offline: [], - invalid: [], - }; - } -}; - -const hashUrlRegex = () => /(.*)([a-z0-9]{40})(.*)/g; - -export const anonymiseOrigins = (origins?: string[]): string[] => { - const originsWithoutSocks = origins?.filter((url) => !url.endsWith('.sock')) ?? []; - return originsWithoutSocks - .map((origin) => - origin - // Replace 40 char hash string with "HASH" - .replace(hashUrlRegex(), '$1HASH$3') - // Replace ipv4 address using . separator with "IPV4ADDRESS" - .replace(ipRegex(), 'IPV4ADDRESS') - // Replace ipv4 address using - separator with "IPV4ADDRESS" - .replace(new RegExp(ipRegex().toString().replace('\\.', '-')), '/IPV4ADDRESS') - // Report WAN port - .replace(`:${getters.config().remote.wanport || 443}`, ':WANPORT') - ) - .filter(Boolean); -}; - -const getAllowedOrigins = (cloud: CloudQueryResult | null, v: Verbosity): string[] | null => { - switch (v) { - case '-vv': - return cloud?.allowedOrigins.filter((url) => !url.endsWith('.sock')) ?? []; - case '-v': - return anonymiseOrigins(cloud?.allowedOrigins ?? []); - default: - return null; - } -}; - -const getReadableCloudDetails = (reportObject: ReportObject, v: Verbosity): string => { - const error = reportObject.cloud.error ? `\n ERROR [${reportObject.cloud.error}]` : ''; - const status = reportObject.cloud.status ? reportObject.cloud.status : 'disconnected'; - const ip = reportObject.cloud.ip && v !== '' ? `\n IP: [${reportObject.cloud.ip}]` : ''; - return ` - STATUS: [${status}] ${ip} ${error}`; -}; - -const getReadableMinigraphDetails = (reportObject: ReportObject): string => { - const statusLine = `STATUS: [${reportObject.minigraph.status}]`; - const errorLine = reportObject.minigraph.error ? ` ERROR: [${reportObject.minigraph.error}]` : null; - const timeoutLine = reportObject.minigraph.timeout - ? ` TIMEOUT: [${(reportObject.minigraph.timeout || 1) / 1_000}s]` - : null; // 1 in case of divide by zero - - return ` - ${statusLine}${errorLine ? `\n${errorLine}` : ''}${timeoutLine ? `\n${timeoutLine}` : ''}`; -}; - -// Convert server to string output -const serverToString = (v: Verbosity) => (server: ServersQueryResultServer) => - `${server?.name ?? 'No Server Name'}${ - v === '-v' || v === '-vv' - ? `[owner="${server.owner?.username ?? 'No Owner Found'}"${ - v === '-vv' ? ` guid="${server.guid ?? 'No GUID'}"]` : ']' - }` - : '' - }`; - -const getReadableServerDetails = (reportObject: ReportObject, v: Verbosity): string => { - if (!reportObject.servers) { - return ''; - } - - if (reportObject.api.status === 'stopped') { - return '\nSERVERS: API is offline'; - } - - const invalid = - (v === '-v' || v === '-vv') && reportObject.servers.invalid.length > 0 - ? ` - INVALID: ${reportObject.servers.invalid.map(serverToString(v)).join(',')}` - : ''; - - return ` -SERVERS: - ONLINE: ${reportObject.servers.online.map(serverToString(v)).join(',')} - OFFLINE: ${reportObject.servers.offline.map(serverToString(v)).join(',')}${invalid}`; -}; - -const getReadableAllowedOrigins = (reportObject: ReportObject): string => { - const { cloud } = reportObject; - if (cloud?.allowedOrigins) { - return ` -ALLOWED_ORIGINS: ${cloud.allowedOrigins.join(', ').trim()}`; - } - - return ''; -}; - -const getVerbosity = (argv: string[]): Verbosity => { - if (argv.includes('-v')) { - return '-v'; - } - - if (argv.includes('-vv')) { - return '-vv'; - } - - return ''; -}; - -export const report = async (...argv: string[]) => { - // Check if the user has raw output enabled - const rawOutput = argv.includes('--raw'); - - // Check if we have a tty attached to stdout - // If we don't then this is being piped to a log file, etc. - const hasTty = process.stdout.isTTY; - - // Check if we should show interactive logs - // If this has a tty it's interactive - // AND - // If they don't have --raw - const isInteractive = hasTty && !rawOutput; - - const stdoutLogger = readLine.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - try { - setEnv('LOG_TYPE', 'raw'); - - // Show loading message - if (isInteractive) { - stdoutLogger.write('Generating report please wait…'); - } - - const jsonReport = argv.includes('--json'); - const v = getVerbosity(argv); - - // Find all processes called "unraid-api" which aren't this process - const unraidApiRunning = await isUnraidApiRunning(); - - // Load my servers config file into store - await store.dispatch(loadConfigFile()); - await store.dispatch(loadStateFiles()); - - const { config, emhttp } = store.getState(); - if (!config.upc.apikey) throw new Error('Missing UPC API key'); - - const client = getApiApolloClient({ localApiKey: config.remote.localApiKey || '' }); - // Fetch the cloud endpoint - const cloud = await getCloudData(client); - - // Log cloud response - cliLogger.trace('Cloud response %s', JSON.stringify(cloud, null, 0)); - - // Query local graphql using upc's API key - // Get the servers array - const servers = await getServersData({ client, v }); - - // Check if the API key is valid - const isApiKeyValid = cloud?.apiKey.valid ?? false; - - const reportObject: ReportObject = { - os: { - serverName: emhttp.var.name, - version: emhttp.var.version, - }, - api: { - version: API_VERSION, - status: unraidApiRunning ? 'running' : 'stopped', - environment: process.env.ENVIRONMENT ?? 'THIS_WILL_BE_REPLACED_WHEN_BUILT', - nodeVersion: process.version, - }, - apiKey: isApiKeyValid ? 'valid' : (cloud?.apiKey.error ?? 'invalid'), - ...(servers ? { servers } : {}), - myServers: { - status: config?.remote?.username ? 'authenticated' : 'signed out', - ...(config?.remote?.username - ? { - myServersUsername: config?.remote?.username?.includes('@') - ? 'REDACTED' - : config?.remote.username, - } - : {}), - }, - minigraph: { - status: cloud?.minigraphql.status ?? MinigraphStatus.PRE_INIT, - timeout: cloud?.minigraphql.timeout ?? null, - error: - (cloud?.minigraphql.error ?? !cloud?.minigraphql.status) ? 'API Disconnected' : null, - }, - cloud: { - status: cloud?.cloud.status ?? 'error', - ...(cloud?.cloud.error ? { error: cloud.cloud.error } : {}), - ...(cloud?.cloud.status === 'ok' ? { ip: cloud.cloud.ip ?? 'NO_IP' } : {}), - ...(getAllowedOrigins(cloud, v) ? { allowedOrigins: getAllowedOrigins(cloud, v) } : {}), - }, - }; - - // If we have trace logs or the user selected --raw don't clear the screen - if (process.env.LOG_LEVEL !== 'trace' && isInteractive) { - // Clear the original log about the report being generated - readLine.cursorTo(process.stdout, 0, 0); - readLine.clearScreenDown(process.stdout); - } - - if (jsonReport) { - stdout.write(JSON.stringify(reportObject) + '\n'); - stdoutLogger.close(); - return reportObject; - } else { - // Generate the actual report - const report = ` -<-----UNRAID-API-REPORT-----> -SERVER_NAME: ${reportObject.os.serverName} -ENVIRONMENT: ${reportObject.api.environment} -UNRAID_VERSION: ${reportObject.os.version} -UNRAID_API_VERSION: ${reportObject.api.version} -UNRAID_API_STATUS: ${reportObject.api.status} -API_KEY: ${reportObject.apiKey} -MY_SERVERS: ${reportObject.myServers.status}${ - reportObject.myServers.myServersUsername - ? `\nMY_SERVERS_USERNAME: ${reportObject.myServers.myServersUsername}` - : '' - } -CLOUD: ${getReadableCloudDetails(reportObject, v)} -MINI-GRAPH: ${getReadableMinigraphDetails(reportObject)}${getReadableServerDetails( - reportObject, - v - )}${getReadableAllowedOrigins(reportObject)} - -`; - - stdout.write(report); - stdoutLogger.close(); - return report; - } - } catch (error: unknown) { - console.log({ error }); - if (error instanceof Error) { - cliLogger.trace(error); - stdoutLogger.write(`\nFailed generating report with "${error.message}"\n`); - return; - } - - stdout.write(`${error as string}`); - stdoutLogger.close(); - } -}; diff --git a/api/src/cli/commands/restart.ts b/api/src/cli/commands/restart.ts deleted file mode 100644 index 187fa266f..000000000 --- a/api/src/cli/commands/restart.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { start } from '@app/cli/commands/start'; -import { stop } from '@app/cli/commands/stop'; - -/** - * Stop a running API process and then start it again. - */ -export const restart = async () => { - await stop(); - await start(); -}; diff --git a/api/src/cli/commands/start.ts b/api/src/cli/commands/start.ts deleted file mode 100644 index dabdb6b97..000000000 --- a/api/src/cli/commands/start.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { PM2_PATH } from '@app/consts'; -import { cliLogger } from '@app/core/log'; -import { execSync } from 'child_process'; -import { join } from 'node:path'; -/** - * Start a new API process. - */ -export const start = async () => { - cliLogger.info('Starting unraid-api with command', `${PM2_PATH} start ${join(import.meta.dirname, 'ecosystem.config.json')} --update-env`); - - execSync(`${PM2_PATH} start ${join(import.meta.dirname, '../../', 'ecosystem.config.json')} --update-env`, { - env: process.env, - stdio: 'inherit', - cwd: process.cwd() - }); -}; diff --git a/api/src/cli/commands/status.ts b/api/src/cli/commands/status.ts deleted file mode 100644 index 060fb697d..000000000 --- a/api/src/cli/commands/status.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { PM2_PATH } from '@app/consts'; -import { execSync } from 'child_process'; - -export const status = async () => { - execSync(`${PM2_PATH} status unraid-api`, { stdio: 'inherit' }); - process.exit(0); -}; diff --git a/api/src/cli/commands/stop.ts b/api/src/cli/commands/stop.ts deleted file mode 100644 index 3b627632b..000000000 --- a/api/src/cli/commands/stop.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { PM2_PATH } from '@app/consts'; -import { execSync } from 'child_process'; - -export const stop = async () => { - execSync(`${PM2_PATH} stop unraid-api`, { stdio: 'inherit' }); -}; diff --git a/api/src/cli/commands/switch-env.ts b/api/src/cli/commands/switch-env.ts deleted file mode 100644 index a7c755902..000000000 --- a/api/src/cli/commands/switch-env.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { copyFile, readFile, writeFile } from 'fs/promises'; -import { join } from 'path'; -import { cliLogger } from '@app/core/log'; -import { getters } from '@app/store'; -import { start } from '@app/cli/commands/start'; -import { stop } from '@app/cli/commands/stop'; - -export const switchEnv = async () => { - const paths = getters.paths(); - const basePath = paths['unraid-api-base']; - const envFlashFilePath = paths['myservers-env']; - const envFile = await readFile(envFlashFilePath, 'utf-8').catch(() => ''); - - await stop(); - - cliLogger.debug( - 'Checking %s for current ENV, found %s', - envFlashFilePath, - envFile - ); - - // Match the env file env="production" which would be [0] = env="production", [1] = env and [2] = production - const matchArray = /([a-zA-Z]+)=["]*([a-zA-Z]+)["]*/.exec(envFile); - // Get item from index 2 of the regex match or return undefined - const [, , currentEnvInFile] = - matchArray && matchArray.length === 3 ? matchArray : []; - - let newEnv = 'production'; - - // Switch from staging to production - if (currentEnvInFile === 'staging') { - newEnv = 'production'; - } - - // Switch from production to staging - if (currentEnvInFile === 'production') { - newEnv = 'staging'; - } - - if (currentEnvInFile) { - cliLogger.debug( - 'Switching from "%s" to "%s"...', - currentEnvInFile, - newEnv - ); - } else { - cliLogger.debug('No ENV found, setting env to "production"...'); - } - - // Write new env to flash - const newEnvLine = `env="${newEnv}"`; - await writeFile(envFlashFilePath, newEnvLine); - cliLogger.debug('Writing %s to %s', newEnvLine, envFlashFilePath); - - // Copy the new env over to live location before restarting - const source = join(basePath, `.env.${newEnv}`); - const destination = join(basePath, '.env'); - - cliLogger.debug('Copying %s to %s', source, destination); - await copyFile(source, destination); - - cliLogger.info('Now using %s', newEnv); - await start(); -}; diff --git a/api/src/cli/index.ts b/api/src/cli/index.ts deleted file mode 100755 index e1d4e46c5..000000000 --- a/api/src/cli/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { execSync } from 'child_process'; - -import type { Flags } from '@app/cli/options'; -import { args, mainOptions, options } from '@app/cli/options'; -import { setEnv } from '@app/cli/set-env'; -import { PM2_PATH } from '@app/consts'; - -const command = mainOptions.command as unknown as string; - -export const main = async (...argv: string[]) => { - if (mainOptions.debug) { - const { cliLogger } = await import('@app/core/log'); - const { getters } = await import('@app/store'); - const ENVIRONMENT = await import('@app/environment'); - cliLogger.debug({ paths: getters.paths(), environment: ENVIRONMENT }, 'Starting CLI'); - } - - setEnv('PORT', process.env.PORT ?? mainOptions.port ?? '9000'); - - if (!command) { - // Run help command - const { parse } = await import('ts-command-line-args'); - parse(args, { - ...options, - partial: true, - stopAtFirstUnknown: true, - argv: ['-h'], - }); - } - - // Only import the command we need when we use it - const commands = { - key: import('@app/cli/commands/key').then((pkg) => pkg.key), - start: import('@app/cli/commands/start').then((pkg) => pkg.start), - stop: import('@app/cli/commands/stop').then((pkg) => pkg.stop), - restart: import('@app/cli/commands/restart').then((pkg) => pkg.restart), - logs: async () => execSync(`${PM2_PATH} logs unraid-api --lines 200`, { stdio: 'inherit' }), - 'switch-env': import('@app/cli/commands/switch-env').then((pkg) => pkg.switchEnv), - version: import('@app/cli/commands/version').then((pkg) => pkg.version), - status: import('@app/cli/commands/status').then((pkg) => pkg.status), - report: import('@app/cli/commands/report').then((pkg) => pkg.report), - 'validate-token': import('@app/cli/commands/validate-token').then((pkg) => pkg.validateToken), - }; - - // Unknown command - if (!Object.keys(commands).includes(command)) { - throw new Error(`Invalid command "${command}"`); - } - - // Resolve the command import - const commandMethod = await commands[command]; - - // Run the command - await commandMethod(...argv); - - process.exit(0); -}; diff --git a/api/src/core/log.ts b/api/src/core/log.ts index 79e040d8e..439544b7a 100644 --- a/api/src/core/log.ts +++ b/api/src/core/log.ts @@ -20,7 +20,6 @@ const level = ] ?? 'info'; export const logDestination = pino.destination({ - minLength: 1_024, sync: true, }); diff --git a/api/src/unraid-api/cli/cli.module.ts b/api/src/unraid-api/cli/cli.module.ts new file mode 100644 index 000000000..2a7eb3ed7 --- /dev/null +++ b/api/src/unraid-api/cli/cli.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; + +import { KeyCommand } from '@app/unraid-api/cli/key.command'; +import { LogService } from '@app/unraid-api/cli/log.service'; +import { ReportCommand } from '@app/unraid-api/cli/report.command'; +import { RestartCommand } from '@app/unraid-api/cli/restart.command'; +import { StartCommand } from '@app/unraid-api/cli/start.command'; +import { StopCommand } from '@app/unraid-api/cli/stop.command'; +import { SwitchEnvCommand } from '@app/unraid-api/cli/switch-env.command'; +import { VersionCommand } from '@app/unraid-api/cli/version.command'; +import { StatusCommand } from '@app/unraid-api/cli/status.command'; +import { ValidateTokenCommand } from '@app/unraid-api/cli/validate-token.command'; + +@Module({ + providers: [ + LogService, + StartCommand, + StopCommand, + RestartCommand, + ReportCommand, + KeyCommand, + SwitchEnvCommand, + VersionCommand, + StatusCommand, + ValidateTokenCommand + ], +}) +export class CliModule {} diff --git a/api/src/unraid-api/cli/key.command.ts b/api/src/unraid-api/cli/key.command.ts new file mode 100644 index 000000000..5b9e2d8ab --- /dev/null +++ b/api/src/unraid-api/cli/key.command.ts @@ -0,0 +1,102 @@ +import { Logger } from '@nestjs/common'; + +import { Command, CommandRunner, Option } from 'nest-commander'; + +import { cliLogger } from '@app/core/log'; +import { Role } from '@app/graphql/generated/api/types'; +import { ApiKeyService } from '@app/unraid-api/auth/api-key.service'; + +interface KeyOptions { + create: boolean; + description?: string; + roles?: Array; + permissions?: Array; +} + +@Command({ name: 'key', arguments: '' }) +export class KeyCommand extends CommandRunner { + private readonly logger = new Logger(KeyCommand.name); + + @Option({ + flags: '--create', + description: 'Create a key if not found', + }) + parseCreate(): boolean { + return true; + } + + @Option({ + flags: '-r, --roles ', + description: `Comma-separated list of roles (${Object.values(Role).join(',')})`, + }) + parseRoles(roles: string): Role[] { + if (!roles) return [Role.GUEST]; + const validRoles: Set = new Set(Object.values(Role)); + + const requestedRoles = roles.split(',').map((role) => role.trim().toLocaleLowerCase() as Role); + const validRequestedRoles = requestedRoles.filter((role) => validRoles.has(role)); + + if (validRequestedRoles.length === 0) { + throw new Error(`Invalid roles. Valid options are: ${Array.from(validRoles).join(', ')}`); + } + + const invalidRoles = requestedRoles.filter((role) => !validRoles.has(role)); + + if (invalidRoles.length > 0) { + cliLogger.warn(`Ignoring invalid roles: ${invalidRoles.join(', ')}`); + } + + return validRequestedRoles; + } + + @Option({ + flags: '-d, --description ', + description: 'Description to assign to the key', + }) + parseDescription(description: string): string { + return description; + } + + @Option({ + flags: '-p, --permissions ', + description: 'Comma separated list of permissions to assign to the key', + }) + parsePermissions(permissions: string) { + throw new Error('Stub Method Until Permissions PR is merged'); + } + + async run(passedParams: string[], options?: KeyOptions): Promise { + console.log(options, passedParams); + + const apiKeyService = new ApiKeyService(); + + const name = passedParams[0]; + const create = options?.create ?? false; + const key = await apiKeyService.findByField('name', name); + if (key) { + this.logger.log(key); + } else if (create) { + if (!options) { + this.logger.error('Invalid Options for Create Flag'); + return; + } + if (options.roles?.length === 0 && options.permissions?.length === 0) { + this.logger.error( + 'Please add at least one role or permission with --roles or --permissions' + ); + return; + } + const key = await apiKeyService.create( + name, + options.description || `CLI generated key: ${name}`, + options.roles ?? [], + true + ); + + this.logger.log(key); + } else { + this.logger.log('No Key Found'); + process.exit(1); + } + } +} diff --git a/api/src/unraid-api/cli/log.service.ts b/api/src/unraid-api/cli/log.service.ts new file mode 100644 index 000000000..e725aeab3 --- /dev/null +++ b/api/src/unraid-api/cli/log.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class LogService { + private logger = console; + + clear(): void { + this.logger.clear(); + } + + log(message: string): void { + this.logger.log(message); + } + + info(message: string): void { + this.logger.info(message); + } + + warn(message: string): void { + this.logger.warn(message); + } + + error(message: string, trace: string = ''): void { + this.logger.error(message, trace); + } + + debug(message: any, ...optionalParams: any[]): void { + this.logger.debug(message, ...optionalParams); + } +} diff --git a/api/src/unraid-api/cli/report.command.ts b/api/src/unraid-api/cli/report.command.ts new file mode 100644 index 000000000..eac87042b --- /dev/null +++ b/api/src/unraid-api/cli/report.command.ts @@ -0,0 +1,376 @@ +import { ApolloClient, ApolloQueryResult, NormalizedCacheObject } from '@apollo/client/core/index.js'; +import ipRegex from 'ip-regex'; +import { Command, CommandRunner, Option } from 'nest-commander'; + +import { isUnraidApiRunning } from '@app/core/utils/pm2/unraid-api-running'; +import { API_VERSION } from '@app/environment'; +import { MinigraphStatus } from '@app/graphql/generated/api/types'; +import { getters, store } from '@app/store'; +import { loadConfigFile } from '@app/store/modules/config'; +import { loadStateFiles } from '@app/store/modules/emhttp'; +import { LogService } from '@app/unraid-api/cli/log.service'; + +import type { getCloudQuery, getServersQuery } from '../../graphql/generated/api/operations'; +import { getApiApolloClient } from '../../graphql/client/api/get-api-client'; +import { getCloudDocument, getServersDocument } from '../../graphql/generated/api/operations'; + +type CloudQueryResult = NonNullable['data']['cloud']>; +type ServersQueryResultServer = NonNullable['data']['servers']>[0]; + +type ServersPayload = { + online: ServersQueryResultServer[]; + offline: ServersQueryResultServer[]; + invalid: ServersQueryResultServer[]; +}; + +type ReportObject = { + os: { + serverName: string; + version: string; + }; + api: { + version: string; + status: 'running' | 'stopped'; + environment: string; + nodeVersion: string; + }; + apiKey: 'valid' | 'invalid' | string; + servers?: ServersPayload | null; + myServers: { + status: 'authenticated' | 'signed out'; + myServersUsername?: string; + }; + minigraph: { + status: MinigraphStatus; + timeout: number | null; + error: string | null; + }; + cloud: { + status: string; + error?: string; + ip?: string; + allowedOrigins?: string[] | null; + }; +}; + +// This should return the status of the apiKey and mothership +export const getCloudData = async ( + client: ApolloClient +): Promise => { + const cloud = await client.query({ query: getCloudDocument }); + return cloud.data.cloud ?? null; +}; + +export const getServersData = async ({ + client, + v, +}: { + client: ApolloClient; + v: number; +}): Promise => { + if (v === 0) { + return null; + } + + try { + const servers = await client.query({ query: getServersDocument }); + const foundServers = servers.data.servers.reduce( + (acc, curr) => { + switch (curr.status) { + case 'online': + acc.online.push(curr); + break; + case 'offline': + acc.offline.push(curr); + break; + default: + acc.invalid.push(curr); + break; + } + + return acc; + }, + { online: [], offline: [], invalid: [] } + ); + return foundServers; + } catch (error: unknown) { + return { + online: [], + offline: [], + invalid: [], + }; + } +}; + +const hashUrlRegex = () => /(.*)([a-z0-9]{40})(.*)/g; + +export const anonymiseOrigins = (origins?: string[]): string[] => { + const originsWithoutSocks = origins?.filter((url) => !url.endsWith('.sock')) ?? []; + return originsWithoutSocks + .map((origin) => + origin + // Replace 40 char hash string with "HASH" + .replace(hashUrlRegex(), '$1HASH$3') + // Replace ipv4 address using . separator with "IPV4ADDRESS" + .replace(ipRegex(), 'IPV4ADDRESS') + // Replace ipv4 address using - separator with "IPV4ADDRESS" + .replace(new RegExp(ipRegex().toString().replace('\\.', '-')), '/IPV4ADDRESS') + // Report WAN port + .replace(`:${getters.config().remote.wanport || 443}`, ':WANPORT') + ) + .filter(Boolean); +}; + +const getAllowedOrigins = (cloud: CloudQueryResult | null, v: number): string[] | null => { + if (v > 1) { + return cloud?.allowedOrigins.filter((url) => !url.endsWith('.sock')) ?? []; + } else if (v === 1) { + return anonymiseOrigins(cloud?.allowedOrigins ?? []); + } + return null; +}; + +const getReadableCloudDetails = (reportObject: ReportObject, v: number): string => { + const error = reportObject.cloud.error ? `\n ERROR [${reportObject.cloud.error}]` : ''; + const status = reportObject.cloud.status ? reportObject.cloud.status : 'disconnected'; + const ip = reportObject.cloud.ip && v !== 0 ? `\n IP: [${reportObject.cloud.ip}]` : ''; + return ` + STATUS: [${status}] ${ip} ${error}`; +}; + +const getReadableMinigraphDetails = (reportObject: ReportObject): string => { + const statusLine = `STATUS: [${reportObject.minigraph.status}]`; + const errorLine = reportObject.minigraph.error ? ` ERROR: [${reportObject.minigraph.error}]` : null; + const timeoutLine = reportObject.minigraph.timeout + ? ` TIMEOUT: [${(reportObject.minigraph.timeout || 1) / 1_000}s]` + : null; // 1 in case of divide by zero + + return ` + ${statusLine}${errorLine ? `\n${errorLine}` : ''}${timeoutLine ? `\n${timeoutLine}` : ''}`; +}; + +// Convert server to string output +const serverToString = (v: number) => (server: ServersQueryResultServer) => + `${server?.name ?? 'No Server Name'}${ + v > 0 + ? `[owner="${server.owner?.username ?? 'No Owner Found'}"${ + v > 1 ? ` guid="${server.guid ?? 'No GUID'}"]` : ']' + }` + : '' + }`; + +const getReadableServerDetails = (reportObject: ReportObject, v: number): string => { + if (!reportObject.servers) { + return ''; + } + + if (reportObject.api.status === 'stopped') { + return '\nSERVERS: API is offline'; + } + + const invalid = + v > 0 && reportObject.servers.invalid.length > 0 + ? ` + INVALID: ${reportObject.servers.invalid.map(serverToString(v)).join(',')}` + : ''; + + return ` +SERVERS: + ONLINE: ${reportObject.servers.online.map(serverToString(v)).join(',')} + OFFLINE: ${reportObject.servers.offline.map(serverToString(v)).join(',')}${invalid}`; +}; + +const getReadableAllowedOrigins = (reportObject: ReportObject): string => { + const { cloud } = reportObject; + if (cloud?.allowedOrigins) { + return ` +ALLOWED_ORIGINS: ${cloud.allowedOrigins.join(', ').trim()}`; + } + + return ''; +}; + +interface ReportOptions { + raw: boolean; + json: boolean; + verbose: number; +} + +@Command({ name: 'report' }) +export class ReportCommand extends CommandRunner { + constructor(private readonly logger: LogService) { + super(); + } + + @Option({ + flags: '-r, --raw', + description: 'whether to enable raw command output', + }) + parseRaw(): boolean { + return true; + } + + @Option({ + flags: '-j, --json', + description: 'Display JSON output for this command', + }) + parseJson(): boolean { + return true; + } + + @Option({ + flags: '-v, --verbose', + description: 'Verbosity level (-v -vv -vvv)', + }) + handleVerbose(value: string | boolean, previous: number): number { + if (typeof value === 'boolean') { + // Single `-v` or `--verbose` flag increments verbosity + return (previous ?? 0) + 1; + } else if (value === undefined) { + return (previous ?? 0) + 1; // Increment if flag is used without value + } else { + // If `-vvv` is passed as one flag, count the number of `v`s + return (previous ?? 0) + value.length; + } + } + + async report(options?: ReportOptions): Promise { + const rawOutput = options?.raw ?? false; + + // Check if we have a tty attached to stdout + // If we don't then this is being piped to a log file, etc. + const hasTty = process.stdout.isTTY; + + // Check if we should show interactive logs + // If this has a tty it's interactive + // AND + // If they don't have --raw + const isInteractive = hasTty && !rawOutput; + + try { + // Show loading message + if (isInteractive) { + this.logger.info('Generating report please wait…'); + } + + const jsonReport = options?.json ?? false; + const v = options?.verbose ?? 0; + + // Find all processes called "unraid-api" which aren't this process + const unraidApiRunning = await isUnraidApiRunning(); + + // Load my servers config file into store + await store.dispatch(loadConfigFile()); + await store.dispatch(loadStateFiles()); + + const { config, emhttp } = store.getState(); + if (!config.upc.apikey) throw new Error('Missing UPC API key'); + + const client = getApiApolloClient({ localApiKey: config.remote.localApiKey || '' }); + // Fetch the cloud endpoint + const cloud = await getCloudData(client) + .then((data) => { + this.logger.debug('Cloud Data', data); + return data; + }) + .catch((error) => { + this.logger.debug( + 'Failed fetching cloud from local graphql with "%s"', + error instanceof Error ? error.message : 'Unknown Error' + ); + return null; + }); + + // Query local graphql using upc's API key + // Get the servers array + const servers = await getServersData({ client, v }); + + // Check if the API key is valid + const isApiKeyValid = cloud?.apiKey.valid ?? false; + + const reportObject: ReportObject = { + os: { + serverName: emhttp.var.name, + version: emhttp.var.version, + }, + api: { + version: API_VERSION, + status: unraidApiRunning ? 'running' : 'stopped', + environment: process.env.ENVIRONMENT ?? 'THIS_WILL_BE_REPLACED_WHEN_BUILT', + nodeVersion: process.version, + }, + apiKey: isApiKeyValid ? 'valid' : (cloud?.apiKey.error ?? 'invalid'), + ...(servers ? { servers } : {}), + myServers: { + status: config?.remote?.username ? 'authenticated' : 'signed out', + ...(config?.remote?.username + ? { + myServersUsername: config?.remote?.username?.includes('@') + ? 'REDACTED' + : config?.remote.username, + } + : {}), + }, + minigraph: { + status: cloud?.minigraphql.status ?? MinigraphStatus.PRE_INIT, + timeout: cloud?.minigraphql.timeout ?? null, + error: + (cloud?.minigraphql.error ?? !cloud?.minigraphql.status) + ? 'API Disconnected' + : null, + }, + cloud: { + status: cloud?.cloud.status ?? 'error', + ...(cloud?.cloud.error ? { error: cloud.cloud.error } : {}), + ...(cloud?.cloud.status === 'ok' ? { ip: cloud.cloud.ip ?? 'NO_IP' } : {}), + ...(getAllowedOrigins(cloud, v) + ? { allowedOrigins: getAllowedOrigins(cloud, v) } + : {}), + }, + }; + + if (jsonReport) { + this.logger.clear(); + this.logger.info(JSON.stringify(reportObject) + '\n'); + return reportObject; + } else { + // Generate the actual report + const report = ` +<-----UNRAID-API-REPORT-----> +SERVER_NAME: ${reportObject.os.serverName} +ENVIRONMENT: ${reportObject.api.environment} +UNRAID_VERSION: ${reportObject.os.version} +UNRAID_API_VERSION: ${reportObject.api.version} +UNRAID_API_STATUS: ${reportObject.api.status} +API_KEY: ${reportObject.apiKey} +MY_SERVERS: ${reportObject.myServers.status}${ + reportObject.myServers.myServersUsername + ? `\nMY_SERVERS_USERNAME: ${reportObject.myServers.myServersUsername}` + : '' + } +CLOUD: ${getReadableCloudDetails(reportObject, v)} +MINI-GRAPH: ${getReadableMinigraphDetails(reportObject)}${getReadableServerDetails( + reportObject, + v + )}${getReadableAllowedOrigins(reportObject)} + +`; + this.logger.clear(); + + this.logger.info(report); + return report; + } + } catch (error: unknown) { + console.log({ error }); + if (error instanceof Error) { + this.logger.debug(error); + this.logger.error(`\nFailed generating report with "${error.message}"\n`); + return; + } + } + } + + async run(_: string[], options?: ReportOptions): Promise { + await this.report(options); + } +} diff --git a/api/src/unraid-api/cli/report.spec.ts b/api/src/unraid-api/cli/report.spec.ts new file mode 100644 index 000000000..5f0e1e670 --- /dev/null +++ b/api/src/unraid-api/cli/report.spec.ts @@ -0,0 +1,18 @@ +import { beforeAll, expect, test } from 'vitest'; + +import { store } from '@app/store'; +import { loadConfigFile } from '@app/store/modules/config'; +import { anonymiseOrigins } from '@app/unraid-api/cli/report.command'; + +beforeAll(async () => { + // Load cfg into store + await store.dispatch(loadConfigFile()); +}); + +test('anonymise origins removes .sock origins', async () => { + expect(anonymiseOrigins(['/var/run/test.sock'])).toEqual([]); +}); + +test('anonymise origins hides WAN port', async () => { + expect(anonymiseOrigins(['https://domain.tld:8443'])).toEqual(['https://domain.tld:WANPORT']); +}); diff --git a/api/src/unraid-api/cli/restart.command.ts b/api/src/unraid-api/cli/restart.command.ts new file mode 100644 index 000000000..290dada60 --- /dev/null +++ b/api/src/unraid-api/cli/restart.command.ts @@ -0,0 +1,22 @@ +import { PM2_PATH } from '@app/consts'; +import { execSync } from 'child_process'; +import { Command, CommandRunner } from 'nest-commander'; +import { join } from 'path'; + +/** + * Stop a running API process and then start it again. + */ +@Command({ name: 'restart', description: 'Restart / Start the Unraid API'}) +export class RestartCommand extends CommandRunner { + async run(_): Promise { + execSync( + `${PM2_PATH} restart ${join(import.meta.dirname, '../../', 'ecosystem.config.json')} --update-env`, + { + env: process.env, + stdio: 'inherit', + cwd: process.cwd(), + } + ); + } + +} \ No newline at end of file diff --git a/api/src/unraid-api/cli/start.command.ts b/api/src/unraid-api/cli/start.command.ts new file mode 100644 index 000000000..c716be506 --- /dev/null +++ b/api/src/unraid-api/cli/start.command.ts @@ -0,0 +1,49 @@ +import { execSync } from 'child_process'; +import { join } from 'path'; + +import { Command, CommandRunner, Option } from 'nest-commander'; + +import { PM2_PATH } from '@app/consts'; +import { levels } from '@app/core/log'; +import { LogService } from '@app/unraid-api/cli/log.service'; + +interface StartCommandOptions { + debug?: boolean; + port?: string; + 'log-level'?: string; + environment?: string; +} + +@Command({ name: 'start' }) +export class StartCommand extends CommandRunner { + constructor(private readonly logger: LogService) { + super(); + } + + async run(_, options: StartCommandOptions): Promise { + this.logger.debug(options); + this.logger.log( + `Starting unraid-api with command: +${PM2_PATH} start ${join(import.meta.dirname, 'ecosystem.config.json')} --update-env` + ); + + execSync( + `${PM2_PATH} start ${join(import.meta.dirname, '../../', 'ecosystem.config.json')} --update-env`, + { + env: process.env, + stdio: 'inherit', + cwd: process.cwd(), + } + ); + } + + @Option({ + flags: '--log-level [string]', + description: 'log level to use', + }) + parseLogLevel(val: unknown): typeof levels { + return (levels.includes(val as (typeof levels)[number]) + ? (val as (typeof levels)[number]) + : 'info') as unknown as typeof levels; + } +} diff --git a/api/src/unraid-api/cli/status.command.ts b/api/src/unraid-api/cli/status.command.ts new file mode 100644 index 000000000..6807863b4 --- /dev/null +++ b/api/src/unraid-api/cli/status.command.ts @@ -0,0 +1,13 @@ +import { execSync } from 'child_process'; + +import { Command, CommandRunner } from 'nest-commander'; + +import { PM2_PATH } from '@app/consts'; + +@Command({ name: 'status', description: 'Check status of unraid-api service' }) +export class StatusCommand extends CommandRunner { + async run(): Promise { + execSync(`${PM2_PATH} status unraid-api`, { stdio: 'inherit' }); + process.exit(0); + } +} diff --git a/api/src/unraid-api/cli/stop.command.ts b/api/src/unraid-api/cli/stop.command.ts new file mode 100644 index 000000000..8020ccc83 --- /dev/null +++ b/api/src/unraid-api/cli/stop.command.ts @@ -0,0 +1,14 @@ +import { execSync } from 'child_process'; + +import { Command, CommandRunner, SubCommand } from 'nest-commander'; + +import { PM2_PATH } from '@app/consts'; + +@Command({ + name: 'stop', +}) +export class StopCommand extends CommandRunner { + async run() { + execSync(`${PM2_PATH} stop unraid-api`, { stdio: 'inherit' }); + } +} diff --git a/api/src/unraid-api/cli/switch-env.command.ts b/api/src/unraid-api/cli/switch-env.command.ts new file mode 100644 index 000000000..2f3afb253 --- /dev/null +++ b/api/src/unraid-api/cli/switch-env.command.ts @@ -0,0 +1,89 @@ +import { copyFile, readFile, writeFile } from 'fs/promises'; +import { join } from 'path'; + +import { Command, CommandRunner, Option } from 'nest-commander'; + +import { cliLogger } from '@app/core/log'; +import { getters } from '@app/store'; +import { LogService } from '@app/unraid-api/cli/log.service'; +import { StartCommand } from '@app/unraid-api/cli/start.command'; +import { StopCommand } from '@app/unraid-api/cli/stop.command'; + +interface SwitchEnvOptions { + environment?: 'staging' | 'production'; +} + +@Command({ + name: 'switch-env', +}) +export class SwitchEnvCommand extends CommandRunner { + private parseStringToEnv(environment: string): 'production' | 'staging' { + return ['production', 'staging'].includes(environment) + ? (environment as 'production' | 'staging') + : 'production'; + } + + @Option({ flags: '-e, --environment ' }) + getEnvOption(environment: string): 'production' | 'staging' { + return this.parseStringToEnv(environment); + } + + constructor( + private readonly logger: LogService, + private readonly stopCommand: StopCommand, + private readonly startCommand: StartCommand + ) { + super(); + } + + private async getEnvironmentFromFile(path: string): Promise<'production' | 'staging'> { + const envFile = await readFile(path, 'utf-8').catch(() => ''); + this.logger.debug(`Checking ${path} for current ENV, found ${envFile}`); + + // Match the env file env="production" which would be [0] = env="production", [1] = env and [2] = production + const matchArray = /([a-zA-Z]+)=["]*([a-zA-Z]+)["]*/.exec(envFile); + // Get item from index 2 of the regex match or return production + const [, , currentEnvInFile] = matchArray && matchArray.length === 3 ? matchArray : []; + return this.parseStringToEnv(currentEnvInFile); + } + + private switchToOtherEnv(environment: 'production' | 'staging'): 'production' | 'staging' { + if (environment === 'production') { + return 'staging'; + } + return 'production'; + } + + async run(_, options: SwitchEnvOptions): Promise { + const paths = getters.paths(); + const basePath = paths['unraid-api-base']; + const envFlashFilePath = paths['myservers-env']; + + this.logger.warn('Stopping the Unraid API'); + try { + await this.stopCommand.run(); + } catch (err) { + this.logger.warn('Failed to stop the Unraid API (maybe already stopped?)'); + } + + const newEnv = + options.environment ?? + this.switchToOtherEnv(await this.getEnvironmentFromFile(envFlashFilePath)); + this.logger.info(`Setting environment to ${newEnv}`); + + // Write new env to flash + const newEnvLine = `env="${newEnv}"`; + this.logger.debug('Writing %s to %s', newEnvLine, envFlashFilePath); + await writeFile(envFlashFilePath, newEnvLine); + + // Copy the new env over to live location before restarting + const source = join(basePath, `.env.${newEnv}`); + const destination = join(basePath, '.env'); + + cliLogger.debug('Copying %s to %s', source, destination); + await copyFile(source, destination); + + cliLogger.info('Now using %s', newEnv); + await this.startCommand.run(null, {}); + } +} \ No newline at end of file diff --git a/api/src/unraid-api/cli/validate-token.command.ts b/api/src/unraid-api/cli/validate-token.command.ts new file mode 100644 index 000000000..6febc5b9d --- /dev/null +++ b/api/src/unraid-api/cli/validate-token.command.ts @@ -0,0 +1,79 @@ +import type { JWTPayload } from 'jose'; +import { createLocalJWKSet, createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose'; +import { Command, CommandRunner } from 'nest-commander'; + +import { JWKS_LOCAL_PAYLOAD, JWKS_REMOTE_LINK } from '@app/consts'; +import { store } from '@app/store'; +import { loadConfigFile } from '@app/store/modules/config'; +import { LogService } from '@app/unraid-api/cli/log.service'; + +const createJsonErrorString = (errorMessage: string) => + JSON.stringify({ + error: errorMessage, + valid: false, + }); + +@Command({ + name: 'validate-token', + description: 'Returns JSON: { error: string | null, valid: boolean }', + arguments: '', +}) +export class ValidateTokenCommand extends CommandRunner { + JWKSOffline: ReturnType; + JWKSOnline: ReturnType; + constructor(private readonly logger: LogService) { + super(); + this.JWKSOffline = createLocalJWKSet(JWKS_LOCAL_PAYLOAD); + this.JWKSOnline = createRemoteJWKSet(new URL(JWKS_REMOTE_LINK)); + } + async run(passedParams: string[]): Promise { + if (passedParams.length !== 1) { + this.logger.error('Please pass token argument only'); + } + + const token = passedParams[0]; + + let caughtError: null | unknown = null; + let tokenPayload: null | JWTPayload = null; + try { + this.logger.debug('Attempting to validate token with local key'); + tokenPayload = (await jwtVerify(token, this.JWKSOffline)).payload; + } catch (error: unknown) { + try { + this.logger.debug('Local validation failed for key, trying remote validation'); + tokenPayload = (await jwtVerify(token, this.JWKSOnline)).payload; + } catch (error: unknown) { + caughtError = error; + } + } + + if (caughtError) { + if (caughtError instanceof Error) { + this.logger.error( + createJsonErrorString(`Caught error validating jwt token: ${caughtError.message}`) + ); + } else { + this.logger.error(createJsonErrorString('Caught error validating jwt token')); + } + } + + if (tokenPayload === null) { + this.logger.error(createJsonErrorString('No data in JWT to use for user validation')); + } + + const username = tokenPayload!.username ?? tokenPayload!['cognito:username']; + const configFile = await store.dispatch(loadConfigFile()).unwrap(); + if (!configFile.remote?.accesstoken) { + this.logger.error(createJsonErrorString('No local user token set to compare to')); + } + + const existingUserPayload = decodeJwt(configFile.remote?.accesstoken); + if (username === existingUserPayload.username) { + this.logger.info(JSON.stringify({ error: null, valid: true })); + } else { + this.logger.error( + createJsonErrorString('Username on token does not match logged in user name') + ); + } + } +} diff --git a/api/src/unraid-api/cli/version.command.ts b/api/src/unraid-api/cli/version.command.ts new file mode 100644 index 000000000..97aac6b29 --- /dev/null +++ b/api/src/unraid-api/cli/version.command.ts @@ -0,0 +1,14 @@ +import { Command, CommandRunner } from 'nest-commander'; + +import { API_VERSION } from '@app/environment'; +import { LogService } from '@app/unraid-api/cli/log.service'; + +@Command({ name: 'version' }) +export class VersionCommand extends CommandRunner { + constructor(private readonly logger: LogService) { + super(); + } + async run(): Promise { + this.logger.info(`Unraid API v${API_VERSION}`); + } +} diff --git a/api/src/unraid-api/rest/rest.service.ts b/api/src/unraid-api/rest/rest.service.ts index 2b0f615c8..8e543d7e3 100644 --- a/api/src/unraid-api/rest/rest.service.ts +++ b/api/src/unraid-api/rest/rest.service.ts @@ -1,42 +1,37 @@ -import { report } from '@app/cli/commands/report'; -import { getBannerPathIfPresent, getCasePathIfPresent } from '@app/core/utils/images/image-file-helpers'; -import { getters } from '@app/store/index'; import { Injectable, Logger } from '@nestjs/common'; -import { execa } from 'execa'; -import { type ReadStream, createReadStream } from 'node:fs'; +import type { ReadStream } from 'node:fs'; +import { createReadStream } from 'node:fs'; import { stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { execa } from 'execa'; + +import { getBannerPathIfPresent, getCasePathIfPresent } from '@app/core/utils/images/image-file-helpers'; +import { getters } from '@app/store/index'; +import { ReportCommand } from '@app/unraid-api/cli/report.command'; + @Injectable() export class RestService { protected logger = new Logger(RestService.name); - async saveApiReport (pathToReport: string) { + async saveApiReport(pathToReport: string) { try { - const apiReport = await report('-vv', '--json'); + const reportCommand = new ReportCommand(); + + const apiReport = await reportCommand.report({ json: true, verbose: 2, raw: false }); this.logger.debug('Report object %o', apiReport); - await writeFile( - pathToReport, - JSON.stringify(apiReport, null, 2), - 'utf-8' - ); + await writeFile(pathToReport, JSON.stringify(apiReport, null, 2), 'utf-8'); } catch (error) { - this.logger.warn( - 'Could not generate report for zip with error %o', - error - ); + this.logger.warn('Could not generate report for zip with error %o', error); } } - + async getLogs(): Promise { const logPath = getters.paths()['log-base']; try { await this.saveApiReport(join(logPath, 'report.json')); } catch (error) { - this.logger.warn( - 'Could not generate report for zip with error %o', - error - ); + this.logger.warn('Could not generate report for zip with error %o', error); } const zipToWrite = join(logPath, '../unraid-api.tar.gz'); @@ -67,7 +62,7 @@ export class RestService { return getCasePathIfPresent(); } } - + async getCustomizationStream(type: 'banner' | 'case'): Promise { const path = await this.getCustomizationPath(type); if (!path) {