From 540c1281be94c87ec31821c4528e28a6a1c19081 Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Mon, 20 Apr 2026 01:55:30 +0200 Subject: [PATCH 01/20] chore(deps): add CLI and import/export dependencies --- package-lock.json | 550 ++++++++++++++++++++++++++++++---------------- package.json | 40 ++-- 2 files changed, 389 insertions(+), 201 deletions(-) diff --git a/package-lock.json b/package-lock.json index cb999924..39ef3f0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,20 +9,29 @@ "version": "2.1.1", "license": "MIT", "dependencies": { + "@clack/prompts": "^1.2.0", "@noble/secp256k1": "1.7.1", "accepts": "^1.3.8", "axios": "^1.15.0", + "cac": "^7.0.0", + "colorette": "^2.0.20", + "debug": "4.3.4", "express": "4.22.1", "js-yaml": "4.1.1", "knex": "2.4.2", + "ora": "^9.3.0", "pg": "8.9.0", "pg-query-stream": "4.3.0", "pino": "^8.21.0", "ramda": "0.28.0", "redis": "4.5.1", + "stream-json": "^2.1.0", "ws": "^8.18.0", "zod": "^3.22.4" }, + "bin": { + "nostream": "dist/src/cli/index.js" + }, "devDependencies": { "@biomejs/biome": "^2.4.11", "@changesets/cli": "^2.27.12", @@ -387,74 +396,6 @@ "@biomejs/cli-win32-x64": "2.4.12" } }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.12.tgz", - "integrity": "sha512-BnMU4Pc3ciEVteVpZ0BK33MLr7X57F5w1dwDLDn+/iy/yTrA4Q/N2yftidFtsA4vrDh0FMXDpacNV/Tl3fbmng==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.12.tgz", - "integrity": "sha512-x9uJ0bI1rJsWICp3VH8w/5PnAVD3A7SqzDpbrfoUQX1QyWrK5jSU4fRLo/wSgGeplCivbxBRKmt5Xq4/nWvq8A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.12.tgz", - "integrity": "sha512-tOwuCuZZtKi1jVzbk/5nXmIsziOB6yqN8c9r9QM0EJYPU6DpQWf11uBOSCfFKKM4H3d9ZoarvlgMfbcuD051Pw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.12.tgz", - "integrity": "sha512-FhfpkAAlKL6kwvcVap0Hgp4AhZmtd3YImg0kK1jd7C/aSoh4SfsB2f++yG1rU0lr8Y5MCFJrcSkmssiL9Xnnig==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, "node_modules/@biomejs/cli-linux-x64": { "version": "2.4.12", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.12.tgz", @@ -489,40 +430,6 @@ "node": ">=14.21.3" } }, - "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.12.tgz", - "integrity": "sha512-B0DLnx0vA9ya/3v7XyCaP+/lCpnbWbMOfUFFve+xb5OxyYvdHaS55YsSddr228Y+JAFk58agCuZTsqNiw2a6ig==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.12.tgz", - "integrity": "sha512-yMckRzTyZ83hkk8iDFWswqSdU8tvZxspJKnYNh7JZr/zhZNOlzH13k4ecboU6MurKExCe2HUkH75pGI/O2JwGA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, "node_modules/@changesets/apply-release-plan": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.1.1.tgz", @@ -975,6 +882,28 @@ "node": ">= 4.0.0" } }, + "node_modules/@clack/core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz", + "integrity": "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.1.3", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.2.0.tgz", + "integrity": "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.2.0", + "fast-string-width": "^1.1.0", + "fast-wrap-ansi": "^0.1.3", + "sisteransi": "^1.0.5" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -3400,6 +3329,15 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz", + "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/caching-transform": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", @@ -3678,6 +3616,33 @@ "node": ">=6" } }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-table3": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz", @@ -3750,9 +3715,9 @@ "license": "MIT" }, "node_modules/colorette": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", - "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "license": "MIT" }, "node_modules/combined-stream": { @@ -4600,6 +4565,21 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz", + "integrity": "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-1.1.0.tgz", + "integrity": "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^1.2.0" + } + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -4617,6 +4597,15 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.1.6.tgz", + "integrity": "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^1.1.0" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -4896,21 +4885,6 @@ "dev": true, "license": "ISC" }, - "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/fsu": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/fsu/-/fsu-1.1.1.tgz", @@ -4956,6 +4930,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -5575,6 +5561,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-iterable": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-iterable/-/is-iterable-1.1.1.tgz", @@ -6074,6 +6072,12 @@ } } }, + "node_modules/knex/node_modules/colorette": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", + "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", + "license": "MIT" + }, "node_modules/knex/node_modules/commander": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", @@ -6457,6 +6461,7 @@ "resolved": "https://registry.npmjs.org/lzma-native/-/lzma-native-8.0.6.tgz", "integrity": "sha512-09xfg67mkL2Lz20PrrDeNYZxzeW7ADtpYFbwSQh9U8+76RIzx5QsJBMy8qikv3hbUPfpy6hqwxt6FcGK81g9AA==", "hasInstallScript": true, + "license": "MIT", "optional": true, "dependencies": { "node-addon-api": "^3.1.0", @@ -6470,20 +6475,6 @@ "node": ">=10.0.0" } }, - "node_modules/lzma-native/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/magic-string": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.16.0.tgz", @@ -6704,6 +6695,18 @@ "node": ">=8" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -7011,21 +7014,6 @@ "node": ">=10" } }, - "node_modules/ndjson/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -7101,12 +7089,14 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "license": "MIT", "optional": true }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", "optional": true, "bin": { "node-gyp-build": "bin.js", @@ -7547,6 +7537,111 @@ "opener": "bin/opener-bin.js" } }, + "node_modules/ora": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", + "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.1", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/outdent": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", @@ -8063,6 +8158,22 @@ "split2": "^4.0.0" } }, + "node_modules/pino-abstract-transport/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/pino-abstract-transport/node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -8733,19 +8844,18 @@ } }, "node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "devOptional": true, "license": "MIT", "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">= 6" } }, "node_modules/readdirp": { @@ -8995,6 +9105,49 @@ "node": ">=8" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -9456,6 +9609,12 @@ "node": ">=8" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -9598,21 +9757,6 @@ "readable-stream": "^3.0.0" } }, - "node_modules/split2/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -9649,6 +9793,39 @@ "node": ">= 0.8" } }, + "node_modules/stdin-discarder": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stream-chain": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-3.6.1.tgz", + "integrity": "sha512-M4BQpNPI71uumkVXjl4y+mIormQXdo4R0pSR23mcLbn6D+kpvu7Kx2g1hf0jRB76Zb1IT1M06OIGghMTAtZdyQ==", + "license": "BSD-3-Clause", + "funding": { + "url": "https://github.com/sponsors/uhop" + } + }, + "node_modules/stream-json": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-2.1.0.tgz", + "integrity": "sha512-9gV/ywtebMn3DdKnNKYCb9iESvgR1dHbucNV+bRGvdvy+jV4c9FFgYKmENhpKv58jSwvs90Wk80RhfKk1KxHPg==", + "license": "BSD-3-Clause", + "dependencies": { + "stream-chain": "^3.6.1" + }, + "funding": { + "url": "https://github.com/sponsors/uhop" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -9992,21 +10169,6 @@ "readable-stream": "3" } }, - "node_modules/through2/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/tildify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", @@ -10914,6 +11076,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yup": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/yup/-/yup-1.2.0.tgz", diff --git a/package.json b/package.json index cb228d21..cbe94bd4 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,16 @@ "11a" ], "main": "src/index.ts", + "bin": { + "nostream": "./dist/src/cli/index.js" + }, "scripts": { + "cli": "node --env-file-if-exists=.env -r ts-node/register src/cli/index.ts", "dev": "node --env-file-if-exists=.env -r ts-node/register src/index.ts", "clean-db": "node --env-file-if-exists=.env -r ts-node/register src/clean-db.ts", "clean": "rimraf ./{dist,.nyc_output,.test-reports,.coverage}", "build": "tsc --project tsconfig.build.json", + "verify:cli:build": "node scripts/verify-cli-build.js", "prestart": "npm run build", "start": "cd dist && node --env-file-if-exists=../.env src/index.js", "build:check": "npm run build -- --noEmit", @@ -39,7 +44,7 @@ "lint:fix": "npm run lint -- --write", "format": "biome format --write ./src ./test", "format:check": "biome format ./src ./test", - "import": "node --env-file-if-exists=.env -r ts-node/register src/import-events.ts", + "import": "npm run cli -- import", "db:migrate": "knex migrate:latest", "db:migrate:rollback": "knex migrate:rollback", "db:seed": "knex seed:run", @@ -47,6 +52,8 @@ "db:verify-index-impact": "node --env-file-if-exists=.env -r ts-node/register scripts/verify-index-impact.ts", "pretest:unit": "node -e \"require('fs').mkdirSync('.test-reports/unit', {recursive: true})\"", "test:unit": "mocha 'test/**/*.spec.ts'", + "pretest:cli": "npm run build", + "test:cli": "mocha 'test/unit/cli/**/*.spec.ts'", "test:unit:watch": "npm run test:unit -- --min --watch --watch-files src/**/*,test/**/*", "cover:unit": "nyc --report-dir .coverage/unit npm run test:unit", "docker:build": "docker build -t nostream .", @@ -55,23 +62,24 @@ "smoke:nip03": "node -r ts-node/register scripts/smoke-nip03.ts", "test:integration": "cucumber-js", "cover:integration": "nyc --report-dir .coverage/integration npm run test:integration -- -p cover", - "export": "node --env-file-if-exists=.env -r ts-node/register src/scripts/export-events.ts", - "docker:compose:start": "./scripts/start", - "docker:compose:stop": "./scripts/stop", - "docker:compose:clean": "./scripts/clean", - "tor:docker:compose:start": "./scripts/start_with_tor", - "tor:hostname": "./scripts/print_tor_hostname", - "tor:docker:compose:stop": "./scripts/stop", - "i2p:docker:compose:start": "./scripts/start_with_i2p", - "i2p:hostname": "./scripts/print_i2p_hostname", - "i2p:docker:compose:stop": "./scripts/stop", + "export": "npm run cli -- export", + "docker:compose:start": "npm run cli -- start", + "docker:compose:stop": "npm run cli -- stop", + "docker:compose:clean": "npm run cli -- dev docker:clean --yes", + "tor:docker:compose:start": "npm run cli -- start --tor", + "tor:hostname": "npm run cli -- info --tor-hostname", + "tor:docker:compose:stop": "npm run cli -- stop --tor", + "i2p:docker:compose:start": "npm run cli -- start --i2p", + "i2p:docker:compose:stop": "npm run cli -- stop --i2p", "docker:integration:run": "docker compose -f ./test/integration/docker-compose.yml run --rm tests", + "test:cli:docker-smoke": "npm run cli -- stop --all && npm run cli -- info", "docker:test:integration": "npm run docker:integration:run -- npm run test:integration", "docker:cover:integration": "npm run docker:integration:run -- npm run cover:integration", "postdocker:integration:run": "docker compose -f ./test/integration/docker-compose.yml down", "prepare": "husky install || exit 0", "changeset:version": "changeset version", - "changeset:tag": "changeset tag" + "changeset:tag": "changeset tag", + "prepack": "npm run build && npm run verify:cli:build" }, "repository": { "type": "git", @@ -82,7 +90,7 @@ "relay", "typescript" ], - "author": "Ricardo Arturo Cabral Mej\u00eda (npub1qqqqqqyz0la2jjl752yv8h7wgs3v098mh9nztd4nr6gynaef6uqqt0n47m)", + "author": "Ricardo Arturo Cabral Mejía (npub1qqqqqqyz0la2jjl752yv8h7wgs3v098mh9nztd4nr6gynaef6uqqt0n47m)", "license": "MIT", "bugs": { "url": "https://github.com/cameri/nostream/issues" @@ -128,17 +136,23 @@ "node": ">=24.14.1" }, "dependencies": { + "@clack/prompts": "^1.2.0", "@noble/secp256k1": "1.7.1", "accepts": "^1.3.8", "axios": "^1.15.0", + "cac": "^7.0.0", + "colorette": "^2.0.20", + "debug": "4.3.4", "express": "4.22.1", "js-yaml": "4.1.1", "knex": "2.4.2", + "ora": "^9.3.0", "pg": "8.9.0", "pg-query-stream": "4.3.0", "pino": "^8.21.0", "ramda": "0.28.0", "redis": "4.5.1", + "stream-json": "^2.1.0", "ws": "^8.18.0", "zod": "^3.22.4" }, From a96789f56e972626021b67ae261ca4471cfe3d0f Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Mon, 20 Apr 2026 01:56:45 +0200 Subject: [PATCH 02/20] feat(import-export): support JSON array workflows --- src/import-events.ts | 192 +++++++++---- src/scripts/export-events.ts | 168 +++++++++-- src/services/event-import-service.ts | 264 +++++++++++------- test/unit/import-events.spec.ts | 2 +- .../services/event-import-service.spec.ts | 164 +++++++++++ 5 files changed, 595 insertions(+), 195 deletions(-) diff --git a/src/import-events.ts b/src/import-events.ts index ea98703d..8288bd7f 100644 --- a/src/import-events.ts +++ b/src/import-events.ts @@ -1,12 +1,8 @@ -import { resolve } from 'path' +import { extname, resolve } from 'path' import fs from 'fs' -import { - CompressionFormat, - createDecompressionStream, - detectCompressionFormat, -} from './utils/compression' +import { CompressionFormat, createDecompressionStream, detectCompressionFormat } from './utils/compression' import { createEventBatchPersister, EventImportLineError, @@ -22,6 +18,18 @@ interface CliOptions { showHelp: boolean } +type ImportFileFormat = 'jsonl' | 'json' + +type RunImportOptions = { + json?: boolean +} + +type InputFileSpec = { + absolutePath: string + compressionFormat?: CompressionFormat + format: ImportFileFormat +} + const DEFAULT_BATCH_SIZE = 1000 const MAX_ERROR_LOGS = 20 @@ -32,10 +40,11 @@ const formatProgress = (stats: EventImportStats): string => { } const printUsage = (): void => { - console.log('Usage: npm run import -- [--batch-size ]') - console.log('Example: npm run import -- ./events.jsonl --batch-size 1000') - console.log('Example: npm run import -- ./events.jsonl.gz') - console.log('Example: npm run import -- ./events.jsonl.xz') + console.log('Usage: nostream import [--batch-size ]') + console.log('Example: nostream import ./events.jsonl --batch-size 1000') + console.log('Example: nostream import ./events.json --batch-size 1000') + console.log('Example: nostream import ./events.jsonl.gz --batch-size 1000') + console.log('Example: nostream import ./events.jsonl.xz --batch-size 1000') } const parseBatchSize = (value: string): number => { @@ -91,7 +100,7 @@ export const parseCliArgs = (args: string[]): CliOptions => { } if (!filePath) { - throw new Error('Missing input file path') + throw new Error('Missing path to .jsonl or .json file') } return { @@ -101,7 +110,21 @@ export const parseCliArgs = (args: string[]): CliOptions => { } } -const ensureValidInputFile = (filePath: string): string => { +const inferCompressedFormat = (absolutePath: string): ImportFileFormat | undefined => { + const normalized = absolutePath.toLowerCase() + + if (normalized.endsWith('.jsonl.gz') || normalized.endsWith('.jsonl.xz')) { + return 'jsonl' + } + + if (normalized.endsWith('.json.gz') || normalized.endsWith('.json.xz')) { + return 'json' + } + + return undefined +} + +const ensureValidInputFile = async (filePath: string): Promise => { const absolutePath = resolve(process.cwd(), filePath) if (!fs.existsSync(absolutePath)) { @@ -113,32 +136,48 @@ const ensureValidInputFile = (filePath: string): string => { throw new Error(`Input path is not a file: ${absolutePath}`) } - return absolutePath -} + const compressionFormat = await detectCompressionFormat(absolutePath) + + if (compressionFormat) { + const format = inferCompressedFormat(absolutePath) + if (!format) { + throw new Error('Compressed input filename must end with .jsonl.gz, .jsonl.xz, .json.gz, or .json.xz') + } -const getCompressionLabel = (format: CompressionFormat): string => { - switch (format) { - case CompressionFormat.GZIP: - return 'gzip' - case CompressionFormat.XZ: - return 'xz' - default: - return String(format) + return { + absolutePath, + compressionFormat, + format, + } } -} -const createImportStream = async (absoluteFilePath: string) => { - const compressionFormat = await detectCompressionFormat(absoluteFilePath) - const source = fs.createReadStream(absoluteFilePath) + const extension = extname(absolutePath).toLowerCase() - if (!compressionFormat) { + if (extension === '.jsonl') { return { - compressionFormat, - stream: source, + absolutePath, + format: 'jsonl', + } + } + + if (extension === '.json') { + return { + absolutePath, + format: 'json', } } - const decompressor = createDecompressionStream(compressionFormat) + throw new Error('Input file must have a .jsonl or .json extension') +} + +const createImportStream = (inputFile: InputFileSpec): NodeJS.ReadableStream => { + const source = fs.createReadStream(inputFile.absolutePath) + + if (!inputFile.compressionFormat) { + return source + } + + const decompressor = createDecompressionStream(inputFile.compressionFormat) source.on('error', (error) => { if (!decompressor.destroyed) { @@ -146,30 +185,37 @@ const createImportStream = async (absoluteFilePath: string) => { } }) - const closeSource = () => { + decompressor.on('close', () => { if (!source.destroyed) { source.destroy() } - } + }) - decompressor.on('close', closeSource) - decompressor.on('error', closeSource) + decompressor.on('error', () => { + if (!source.destroyed) { + source.destroy() + } + }) - return { - compressionFormat, - stream: source.pipe(decompressor), - } + return source.pipe(decompressor) } -const run = async (): Promise => { - const options = parseCliArgs(process.argv.slice(2)) +export const runImportEvents = async ( + args: string[] = process.argv.slice(2), + runOptions: RunImportOptions = {}, +): Promise => { + const options = parseCliArgs(args) if (options.showHelp) { printUsage() - return + return 0 } - const absoluteFilePath = ensureValidInputFile(options.filePath) + const inputFile = await ensureValidInputFile(options.filePath) + + if (inputFile.compressionFormat && inputFile.format === 'json') { + throw new Error('Compressed JSON array import is not supported. Use .json (uncompressed) or .jsonl.gz/.jsonl.xz.') + } const dbClient = getMasterDbClient() const eventRepository = new EventRepository(dbClient, dbClient) @@ -195,16 +241,22 @@ const run = async (): Promise => { const startedAt = Date.now() try { - const { stream, compressionFormat } = await createImportStream(absoluteFilePath) - if (compressionFormat) { - console.log(`Detected ${getCompressionLabel(compressionFormat)} compression. Decompressing on-the-fly...`) + if (inputFile.compressionFormat) { + console.log(`Detected ${inputFile.compressionFormat} compression. Decompressing on-the-fly...`) } - const stats = await importer.importFromReadable(stream, { - batchSize: options.batchSize, - onLineError, - onProgress, - }) + const stats = + inputFile.format === 'json' + ? await importer.importFromJsonArray(inputFile.absolutePath, { + batchSize: options.batchSize, + onLineError, + onProgress, + }) + : await importer.importFromReadable(createImportStream(inputFile), { + batchSize: options.batchSize, + onLineError, + onProgress, + }) if (suppressedErrors > 0) { console.warn(`Suppressed ${formatNumber(suppressedErrors)} additional line errors`) @@ -212,21 +264,41 @@ const run = async (): Promise => { const elapsedSeconds = ((Date.now() - startedAt) / 1000).toFixed(2) - console.log(`Import completed in ${elapsedSeconds}s`) - console.log(formatProgress(stats)) + if (runOptions.json) { + console.log( + JSON.stringify( + { + elapsedSeconds: Number(elapsedSeconds), + ...stats, + suppressedErrors, + }, + null, + 2, + ), + ) + } else { + console.log(`Import completed in ${elapsedSeconds}s`) + console.log(formatProgress(stats)) + } + + return 0 } finally { await dbClient.destroy() } } if (require.main === module) { - run().catch((error: unknown) => { - if (error instanceof Error) { - console.error(`Import failed: ${error.message}`) - } else { - console.error('Import failed with unknown error') - } + runImportEvents() + .then((exitCode) => { + process.exitCode = exitCode + }) + .catch((error: unknown) => { + if (error instanceof Error) { + console.error(`Import failed: ${error.message}`) + } else { + console.error('Import failed with unknown error') + } - process.exit(1) - }) + process.exit(1) + }) } diff --git a/src/scripts/export-events.ts b/src/scripts/export-events.ts index 56d4752d..c2af0a13 100644 --- a/src/scripts/export-events.ts +++ b/src/scripts/export-events.ts @@ -1,7 +1,7 @@ import 'pg-query-stream' import fs from 'fs' -import path from 'path' +import path, { extname } from 'path' import { pipeline } from 'stream/promises' import { Transform } from 'stream' @@ -20,6 +20,11 @@ type ExportCliOptions = { showHelp: boolean } +type ExportOptions = { + json?: boolean + format?: 'jsonl' | 'json' +} + const DEFAULT_OUTPUT_FILE_PATH = 'events.jsonl' const MIN_ELAPSED_SECONDS = 0.001 @@ -76,9 +81,9 @@ const formatCount = (value: number): string => { return Number.isInteger(rounded) ? rounded.toLocaleString('en-US') : rounded.toLocaleString('en-US', { - maximumFractionDigits: 2, - minimumFractionDigits: 2, - }) + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }) } const getOptionValue = (option: string, args: string[], index: number): [string, number] => { @@ -187,14 +192,83 @@ type EventRow = { event_signature: Buffer } -async function exportEvents(): Promise { - const options = parseCliArgs(process.argv.slice(2)) - if (options.showHelp) { +const resolveExportFormat = (format?: string): 'jsonl' | 'json' => { + if (!format) { + return 'jsonl' + } + + if (format === 'jsonl' || format === 'json') { + return format + } + + throw new Error(`Unsupported format: ${format}. Supported values: json, jsonl`) +} + +const resolveOutputPath = (filename: string | undefined, format: 'jsonl' | 'json'): string => { + const fallback = format === 'json' ? 'events.json' : 'events.jsonl' + const outputPath = path.resolve(filename || fallback) + const expectedExtension = format === 'json' ? '.json' : '.jsonl' + + if (extname(outputPath).toLowerCase() !== expectedExtension) { + throw new Error(`Output file extension must be ${expectedExtension} when using --format ${format}`) + } + + return outputPath +} + +const toEvent = (row: EventRow) => ({ + id: row.event_id.toString('hex'), + pubkey: row.event_pubkey.toString('hex'), + created_at: row.event_created_at, + kind: row.event_kind, + tags: Array.isArray(row.event_tags) ? row.event_tags : [], + content: row.event_content, + sig: row.event_signature.toString('hex'), +}) + +const createFormatterTransform = ( + format: 'jsonl' | 'json', + onExported: () => void, +): Transform => { + if (format === 'jsonl') { + return new Transform({ + objectMode: true, + transform(row: EventRow, _encoding, callback) { + onExported() + callback(null, JSON.stringify(toEvent(row)) + '\n') + }, + }) + } + + let hasRows = false + return new Transform({ + objectMode: true, + transform(row: EventRow, _encoding, callback) { + const prefix = hasRows ? ',\n' : '[\n' + hasRows = true + onExported() + callback(null, prefix + JSON.stringify(toEvent(row))) + }, + flush(callback) { + callback(null, hasRows ? '\n]\n' : '[]\n') + }, + }) +} + +export async function runExportEvents(args: string[] = process.argv.slice(2), options: ExportOptions = {}): Promise { + const useStructuredFormat = Boolean(options.format) + const structuredFormat = resolveExportFormat(options.format) + const cliOptions = useStructuredFormat ? undefined : parseCliArgs(args) + + if (!useStructuredFormat && cliOptions?.showHelp) { printUsage() - return + return 0 } - const outputPath = path.resolve(options.outputFilePath) + const outputPath = useStructuredFormat + ? resolveOutputPath(args[0], structuredFormat) + : path.resolve(cliOptions?.outputFilePath ?? DEFAULT_OUTPUT_FILE_PATH) + const db = getMasterDbClient() const abortController = new AbortController() let interruptedBySignal: NodeJS.Signals | undefined @@ -216,25 +290,28 @@ async function exportEvents(): Promise { const firstEvent = await db('events').select('event_id').whereNull('deleted_at').first() if (abortController.signal.aborted) { - return + return 130 } if (!firstEvent) { - console.log('No events to export.') - return + if (options.json) { + console.log(JSON.stringify({ exported: 0, outputPath, empty: true }, null, 2)) + } else { + console.log('No events to export.') + } + return 0 } - if (options.format) { - console.log(`Exporting events to ${outputPath} using ${getCompressionLabel(options.format)} compression`) + if (useStructuredFormat) { + console.log(`Exporting events to ${outputPath}`) + } else if (cliOptions?.format) { + console.log(`Exporting events to ${outputPath} using ${getCompressionLabel(cliOptions.format)} compression`) } else { console.log(`Exporting events to ${outputPath}`) } const startedAt = Date.now() const output = fs.createWriteStream(outputPath) - const compressionStream = createCompressionStream(options.format) - let exported = 0 - let rawBytes = 0 const dbStream = db('events') .select( @@ -251,25 +328,52 @@ async function exportEvents(): Promise { .orderBy('event_id', 'asc') .stream() + let exported = 0 + + if (useStructuredFormat) { + const formatter = createFormatterTransform(structuredFormat, () => { + exported += 1 + if (exported % 10000 === 0) { + console.log(`Exported ${exported} events...`) + } + }) + + await pipeline(dbStream, formatter, output, { + signal: abortController.signal, + }) + + if (options.json) { + console.log( + JSON.stringify( + { + exported, + outputPath, + format: structuredFormat, + }, + null, + 2, + ), + ) + } else { + console.log(`Export complete: ${exported} events written to ${outputPath} (${structuredFormat})`) + } + + return 0 + } + + const compressionFormat = cliOptions?.format + const compressionStream = createCompressionStream(compressionFormat) + let rawBytes = 0 + const toJsonLine = new Transform({ objectMode: true, transform(row: EventRow, _encoding, callback) { - const event = { - id: row.event_id.toString('hex'), - pubkey: row.event_pubkey.toString('hex'), - created_at: row.event_created_at, - kind: row.event_kind, - tags: Array.isArray(row.event_tags) ? row.event_tags : [], - content: row.event_content, - sig: row.event_signature.toString('hex'), - } - - exported++ + exported += 1 if (exported % 10000 === 0) { console.log(`Exported ${exported} events...`) } - const line = JSON.stringify(event) + '\n' + const line = JSON.stringify(toEvent(row)) + '\n' rawBytes += Buffer.byteLength(line) callback(null, line) }, @@ -296,11 +400,13 @@ async function exportEvents(): Promise { console.log( `Throughput: ${formatCount(eventRate)} events/s | ${formatBytes(rawRate)}/s raw | ${formatBytes(outputRate)}/s output`, ) + + return 0 } catch (error) { if (abortController.signal.aborted) { console.log(`Export interrupted by ${interruptedBySignal ?? 'signal'}.`) process.exitCode = 130 - return + return 130 } throw error @@ -312,7 +418,7 @@ async function exportEvents(): Promise { } if (require.main === module) { - exportEvents().catch((error) => { + runExportEvents().catch((error) => { console.error('Export failed:', error.message) process.exit(1) }) diff --git a/src/services/event-import-service.ts b/src/services/event-import-service.ts index c7071ce9..68f8cdf7 100644 --- a/src/services/event-import-service.ts +++ b/src/services/event-import-service.ts @@ -1,6 +1,10 @@ import fs from 'fs' import readline from 'readline' +const streamArray = require('stream-json/streamers/stream-array.js') as { + withParserAsStream: () => NodeJS.ReadWriteStream +} + import { getEventExpiration, isDeleteEvent, @@ -61,6 +65,12 @@ export interface EventImportOptions { onProgress?: (stats: EventImportStats) => void } +type EventImportCandidate = { + candidate?: unknown + parseError?: unknown + recordNumber: number +} + const getErrorMessage = (error: unknown): string => { if (error instanceof Error) { return error.message @@ -71,7 +81,7 @@ const getErrorMessage = (error: unknown): string => { const isDestroyableStream = ( stream: NodeJS.ReadableStream, -): stream is NodeJS.ReadableStream & { destroy: () => void } => { +): stream is NodeJS.ReadableStream & { destroy: (error?: Error) => void } => { const candidate = stream as { destroy?: unknown } return typeof candidate.destroy === 'function' @@ -79,61 +89,57 @@ const isDestroyableStream = ( export const createEventBatchPersister = (eventRepository: IEventRepository) => - async (events: Event[]): Promise => { - if (!events.length) { - return 0 - } - - let inserted = 0 + async (events: Event[]): Promise => { + if (!events.length) { + return 0 + } - const regularEvents: Event[] = [] - const replaceableEvents: Event[] = [] + let inserted = 0 - for (const event of events) { - if (isEphemeralEvent(event)) { - continue - } + const regularEvents: Event[] = [] + const replaceableEvents: Event[] = [] - if (isDeleteEvent(event)) { - // flush pending batches before applying deletes - inserted += await eventRepository.createMany(regularEvents.splice(0)) - inserted += await eventRepository.upsertMany(replaceableEvents.splice(0)) - - const eventIdsToDelete = event.tags.reduce( - (ids, tag) => - tag.length >= 2 - && tag[0] === EventTags.Event - && /^[0-9a-f]{64}$/.test(tag[1]) - ? [...ids, tag[1]] - : ids, - [] as string[] - ) - - if (eventIdsToDelete.length) { - await eventRepository.deleteByPubkeyAndIds(event.pubkey, eventIdsToDelete) - } + for (const event of events) { + if (isEphemeralEvent(event)) { + continue + } - inserted += await eventRepository.create(enrichEventMetadata(event)) - continue - } + if (isDeleteEvent(event)) { + // flush pending batches before applying deletes + inserted += await eventRepository.createMany(regularEvents.splice(0)) + inserted += await eventRepository.upsertMany(replaceableEvents.splice(0)) - const enrichedEvent = enrichEventMetadata(event) + const eventIdsToDelete = event.tags.reduce( + (ids, tag) => + tag.length >= 2 && tag[0] === EventTags.Event && /^[0-9a-f]{64}$/.test(tag[1]) ? [...ids, tag[1]] : ids, + [] as string[], + ) - if (isReplaceableEvent(event) || isParameterizedReplaceableEvent(event)) { - replaceableEvents.push(enrichedEvent) - continue + if (eventIdsToDelete.length) { + await eventRepository.deleteByPubkeyAndIds(event.pubkey, eventIdsToDelete) } - regularEvents.push(enrichedEvent) + inserted += await eventRepository.create(enrichEventMetadata(event)) + continue } - // flush remaining - inserted += await eventRepository.createMany(regularEvents) - inserted += await eventRepository.upsertMany(replaceableEvents) + const enrichedEvent = enrichEventMetadata(event) + + if (isReplaceableEvent(event) || isParameterizedReplaceableEvent(event)) { + replaceableEvents.push(enrichedEvent) + continue + } - return inserted + regularEvents.push(enrichedEvent) } + // flush remaining + inserted += await eventRepository.createMany(regularEvents) + inserted += await eventRepository.upsertMany(replaceableEvents) + + return inserted + } + export class EventImportService { public constructor( private readonly persistBatch: (events: Event[]) => Promise, @@ -143,11 +149,87 @@ export class EventImportService { input: NodeJS.ReadableStream, options: EventImportOptions = {}, ): Promise { - const batchSize = ( - typeof options.batchSize === 'number' - && Number.isInteger(options.batchSize) - && options.batchSize > 0 - ) ? options.batchSize : DEFAULT_BATCH_SIZE + return this.importFromCandidates(this.readJsonlCandidatesFromStream(input), options) + } + + public async importFromJsonl(filePath: string, options: EventImportOptions = {}): Promise { + const stream = fs.createReadStream(filePath, { + encoding: 'utf-8', + }) + + return this.importFromReadable(stream, options) + } + + public async importFromJsonArray(filePath: string, options: EventImportOptions = {}): Promise { + return this.importFromCandidates(this.readJsonArrayCandidates(filePath), options) + } + + private async *readJsonlCandidatesFromStream(input: NodeJS.ReadableStream): AsyncGenerator { + const lineReader = readline.createInterface({ + crlfDelay: Infinity, + input, + }) + + let lineNumber = 0 + + try { + for await (const line of lineReader) { + lineNumber += 1 + + const trimmedLine = line.trim() + if (!trimmedLine.length) { + continue + } + + try { + yield { + recordNumber: lineNumber, + candidate: JSON.parse(trimmedLine), + } + } catch (error) { + yield { + recordNumber: lineNumber, + parseError: error, + } + } + } + } finally { + lineReader.close() + if (isDestroyableStream(input)) { + input.destroy() + } + } + } + + private async *readJsonArrayCandidates(filePath: string): AsyncGenerator { + const source = fs.createReadStream(filePath, { + encoding: 'utf-8', + }) + const arrayStream = streamArray.withParserAsStream() + const pipeline = source.pipe(arrayStream) + + try { + for await (const chunk of pipeline as AsyncIterable<{ key: number; value: unknown }>) { + yield { + recordNumber: chunk.key + 1, + candidate: chunk.value, + } + } + } catch (error) { + throw new Error(`Invalid JSON array input: ${getErrorMessage(error)}`) + } finally { + source.destroy() + } + } + + private async importFromCandidates( + candidates: AsyncIterable, + options: EventImportOptions = {}, + ): Promise { + const batchSize = + typeof options.batchSize === 'number' && Number.isInteger(options.batchSize) && options.batchSize > 0 + ? options.batchSize + : DEFAULT_BATCH_SIZE const onLineError = options.onLineError ?? (() => undefined) const onProgress = options.onProgress ?? (() => undefined) @@ -162,8 +244,6 @@ export class EventImportService { skipped: 0, } - let lineNumber = 0 - const flushBatch = async () => { if (!batch.length) { return @@ -173,9 +253,7 @@ export class EventImportService { const inserted = await this.persistBatch(batch) if (!Number.isInteger(inserted) || inserted < 0 || inserted > currentBatchSize) { - throw new Error( - `Invalid insert count (${inserted}) for batch size ${currentBatchSize}`, - ) + throw new Error(`Invalid insert count (${inserted}) for batch size ${currentBatchSize}`) } stats.inserted += inserted @@ -185,69 +263,49 @@ export class EventImportService { onProgress({ ...stats }) } - const lineReader = readline.createInterface({ - crlfDelay: Infinity, - input, - }) - - try { - for await (const line of lineReader) { - lineNumber += 1 - - const trimmedLine = line.trim() - if (!trimmedLine.length) { - continue - } - + for await (const { recordNumber, candidate, parseError } of candidates) { + if (parseError) { stats.processed += 1 + stats.errors += 1 + onLineError({ + lineNumber: recordNumber, + reason: getErrorMessage(parseError), + }) + continue + } - let event: Event - try { - event = validateEventSchema(JSON.parse(trimmedLine)) as Event + stats.processed += 1 - if (!await isEventIdValid(event)) { - throw new Error('invalid: event id does not match') - } - - if (!await isEventSignatureValid(event)) { - throw new Error('invalid: event signature verification failed') - } - } catch (error) { - stats.errors += 1 - onLineError({ - lineNumber, - reason: getErrorMessage(error), - }) + let event: Event + try { + event = validateEventSchema(candidate) as Event - continue + if (!(await isEventIdValid(event))) { + throw new Error('invalid: event id does not match') } - batch.push(event) - - if (batch.length >= batchSize) { - await flushBatch() + if (!(await isEventSignatureValid(event))) { + throw new Error('invalid: event signature verification failed') } + } catch (error) { + stats.errors += 1 + onLineError({ + lineNumber: recordNumber, + reason: getErrorMessage(error), + }) + + continue } - await flushBatch() + batch.push(event) - return stats - } finally { - lineReader.close() - if (isDestroyableStream(input)) { - input.destroy() + if (batch.length >= batchSize) { + await flushBatch() } } - } - public async importFromJsonl( - filePath: string, - options: EventImportOptions = {}, - ): Promise { - const stream = fs.createReadStream(filePath, { - encoding: 'utf-8', - }) + await flushBatch() - return this.importFromReadable(stream, options) + return stats } } diff --git a/test/unit/import-events.spec.ts b/test/unit/import-events.spec.ts index 00cf5779..1c1756a3 100644 --- a/test/unit/import-events.spec.ts +++ b/test/unit/import-events.spec.ts @@ -44,7 +44,7 @@ describe('parseCliArgs (import-events)', () => { }) it('throws when input file path is missing', () => { - expect(() => parseCliArgs([])).to.throw('Missing input file path') + expect(() => parseCliArgs([])).to.throw('Missing path to .jsonl or .json file') }) it('throws on unknown options including short options', () => { diff --git a/test/unit/services/event-import-service.spec.ts b/test/unit/services/event-import-service.spec.ts index 359f365e..8aa91870 100644 --- a/test/unit/services/event-import-service.spec.ts +++ b/test/unit/services/event-import-service.spec.ts @@ -31,6 +31,18 @@ describe('EventImportService', () => { return filePath } + const createJsonArrayFile = (value: unknown): string => { + const tmpDir = fs.mkdtempSync(join(os.tmpdir(), 'nostream-import-array-')) + tmpDirs.push(tmpDir) + + const filePath = join(tmpDir, 'events.json') + fs.writeFileSync(filePath, JSON.stringify(value), { + encoding: 'utf-8', + }) + + return filePath + } + afterEach(() => { for (const tmpDir of tmpDirs.splice(0)) { fs.rmSync(tmpDir, { @@ -107,6 +119,44 @@ describe('EventImportService', () => { }) }) + it('imports valid events from JSON array in batches and tracks skipped duplicates', async () => { + const [event] = getEvents() + const filePath = createJsonArrayFile([event, event, event]) + + const batchCalls: Event[][] = [] + const persistBatch = async (events: Event[]): Promise => { + batchCalls.push([...events]) + + if (batchCalls.length === 1) { + return 2 + } + + return 0 + } + + const progressUpdates: EventImportStats[] = [] + + const importer = new EventImportService(persistBatch) + + const stats = await importer.importFromJsonArray(filePath, { + batchSize: 2, + onProgress: (progress) => { + progressUpdates.push(progress) + }, + }) + + expect(stats).to.deep.equal({ + errors: 0, + inserted: 2, + processed: 3, + skipped: 1, + }) + + expect(batchCalls.length).to.equal(2) + expect(progressUpdates.length).to.equal(2) + expect(progressUpdates[progressUpdates.length - 1]).to.deep.equal(stats) + }) + it('counts malformed and invalid events as errors and keeps importing', async () => { const [event] = getEvents() @@ -157,6 +207,50 @@ describe('EventImportService', () => { expect(lineErrors.length).to.equal(3) }) + it('counts malformed and invalid events in JSON array as errors and keeps importing', async () => { + const [event] = getEvents() + + const invalidIdEvent: Event = { + ...event, + content: `${event.content} changed`, + } + + const invalidSignatureEvent: Event = { + ...event, + sig: 'f'.repeat(128), + } + + const filePath = createJsonArrayFile([event, 'not-an-event', invalidIdEvent, invalidSignatureEvent]) + + const batchCalls: Event[][] = [] + const persistBatch = async (events: Event[]): Promise => { + batchCalls.push([...events]) + return 1 + } + + const lineErrors: EventImportLineError[] = [] + + const importer = new EventImportService(persistBatch) + + const stats = await importer.importFromJsonArray(filePath, { + batchSize: 10, + onLineError: (lineError) => { + lineErrors.push(lineError) + }, + }) + + expect(stats).to.deep.equal({ + errors: 3, + inserted: 1, + processed: 4, + skipped: 0, + }) + expect(batchCalls.length).to.equal(1) + expect(batchCalls[0].length).to.equal(1) + expect(lineErrors.length).to.equal(3) + expect(lineErrors.map((item) => item.lineNumber)).to.deep.equal([2, 3, 4]) + }) + it('rejects when persistence returns an invalid insert count', async () => { const [event] = getEvents() const filePath = createJsonlFile([JSON.stringify(event)]) @@ -173,6 +267,22 @@ describe('EventImportService', () => { } }) + it('rejects JSON array import when persistence returns an invalid insert count', async () => { + const [event] = getEvents() + const filePath = createJsonArrayFile([event]) + + const persistBatch = async (): Promise => 2 + + const importer = new EventImportService(persistBatch) + + try { + await importer.importFromJsonArray(filePath) + expect.fail('Expected import to reject when persistence returns invalid insert count') + } catch (error) { + expect((error as Error).message).to.include('Invalid insert count') + } + }) + it('propagates persistence failures as import failures', async () => { const [event] = getEvents() const filePath = createJsonlFile([JSON.stringify(event)]) @@ -198,6 +308,60 @@ describe('EventImportService', () => { } }) + it('propagates persistence failures as JSON array import failures', async () => { + const [event] = getEvents() + const filePath = createJsonArrayFile([event]) + + const persistBatch = async (): Promise => { + throw new Error('database unavailable') + } + + const lineErrors: EventImportLineError[] = [] + + const importer = new EventImportService(persistBatch) + + try { + await importer.importFromJsonArray(filePath, { + onLineError: (lineError) => { + lineErrors.push(lineError) + }, + }) + expect.fail('Expected import to reject when persistence fails') + } catch (error) { + expect((error as Error).message).to.equal('database unavailable') + expect(lineErrors.length).to.equal(0) + } + }) + + it('fails fast for malformed top-level JSON in JSON array mode', async () => { + const tmpDir = fs.mkdtempSync(join(os.tmpdir(), 'nostream-import-array-malformed-')) + tmpDirs.push(tmpDir) + const filePath = join(tmpDir, 'events.json') + fs.writeFileSync(filePath, '{"broken":', 'utf-8') + + const importer = new EventImportService(async () => 0) + + try { + await importer.importFromJsonArray(filePath) + expect.fail('Expected malformed top-level JSON to fail') + } catch (error) { + expect((error as Error).message).to.include('Invalid JSON array input:') + } + }) + + it('fails fast for non-array top-level JSON in JSON array mode', async () => { + const filePath = createJsonArrayFile({ foo: 'bar' }) + + const importer = new EventImportService(async () => 0) + + try { + await importer.importFromJsonArray(filePath) + expect.fail('Expected non-array top-level JSON to fail') + } catch (error) { + expect((error as Error).message).to.include('Invalid JSON array input:') + } + }) + it('normalizes parameterized replaceable deduplication to first d tag value', async () => { const parameterizedEvent: Event = { id: 'a'.repeat(64), From 398ae0c81c1801ce0d4110d0e26dec04a45edf3d Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Mon, 20 Apr 2026 02:00:02 +0200 Subject: [PATCH 03/20] feat(cli): add nostream CLI and replace shell scripts --- docker-compose.yml | 14 +- scripts/clean | 8 - scripts/print_i2p_hostname | 23 -- scripts/print_tor_hostname | 4 - scripts/start | 73 ---- scripts/start_with_i2p | 40 --- scripts/start_with_nginx | 81 ----- scripts/start_with_tor | 38 --- scripts/stop | 15 - scripts/update | 13 - scripts/verify-cli-build.js | 43 +++ seeds/0000-events.js | 83 ++++- src/clean-db.ts | 10 +- src/cli/commands/config.ts | 232 +++++++++++++ src/cli/commands/dev.ts | 139 ++++++++ src/cli/commands/export.ts | 54 +++ src/cli/commands/import.ts | 20 ++ src/cli/commands/info.ts | 163 +++++++++ src/cli/commands/seed.ts | 33 ++ src/cli/commands/setup.ts | 84 +++++ src/cli/commands/start.ts | 141 ++++++++ src/cli/commands/stop.ts | 40 +++ src/cli/commands/update.ts | 64 ++++ src/cli/index.ts | 465 ++++++++++++++++++++++++++ src/cli/tui/main.ts | 59 ++++ src/cli/tui/menus/configure.ts | 112 +++++++ src/cli/tui/menus/dev.ts | 98 ++++++ src/cli/tui/menus/manage.ts | 124 +++++++ src/cli/tui/menus/start.ts | 84 +++++ src/cli/tui/menus/stop.ts | 27 ++ src/cli/tui/prompts.ts | 11 + src/cli/tui/state.ts | 7 + src/cli/types.ts | 27 ++ src/cli/utils/bootstrap.ts | 38 +++ src/cli/utils/config.ts | 420 +++++++++++++++++++++++ src/cli/utils/docker.ts | 47 +++ src/cli/utils/env-config.ts | 215 ++++++++++++ src/cli/utils/formatting.ts | 3 + src/cli/utils/output.ts | 31 ++ src/cli/utils/paths.ts | 13 + src/cli/utils/process.ts | 56 ++++ src/cli/utils/validation.ts | 11 + test/unit/cli/cli.integration.spec.ts | 356 ++++++++++++++++++++ test/unit/cli/commands.spec.ts | 18 + test/unit/cli/config.spec.ts | 78 +++++ test/unit/cli/docker.spec.ts | 34 ++ test/unit/cli/export-command.spec.ts | 46 +++ test/unit/cli/export.spec.ts | 117 +++++++ test/unit/cli/import.runtime.spec.ts | 86 +++++ test/unit/cli/tui.spec.ts | 147 ++++++++ test/unit/cli/update.spec.ts | 59 ++++ test/unit/seeds/0000-events.spec.ts | 92 +++++ 52 files changed, 3985 insertions(+), 311 deletions(-) delete mode 100755 scripts/clean delete mode 100644 scripts/print_i2p_hostname delete mode 100755 scripts/print_tor_hostname delete mode 100755 scripts/start delete mode 100644 scripts/start_with_i2p delete mode 100755 scripts/start_with_nginx delete mode 100755 scripts/start_with_tor delete mode 100755 scripts/stop delete mode 100755 scripts/update create mode 100644 scripts/verify-cli-build.js create mode 100644 src/cli/commands/config.ts create mode 100644 src/cli/commands/dev.ts create mode 100644 src/cli/commands/export.ts create mode 100644 src/cli/commands/import.ts create mode 100644 src/cli/commands/info.ts create mode 100644 src/cli/commands/seed.ts create mode 100644 src/cli/commands/setup.ts create mode 100644 src/cli/commands/start.ts create mode 100644 src/cli/commands/stop.ts create mode 100644 src/cli/commands/update.ts create mode 100644 src/cli/index.ts create mode 100644 src/cli/tui/main.ts create mode 100644 src/cli/tui/menus/configure.ts create mode 100644 src/cli/tui/menus/dev.ts create mode 100644 src/cli/tui/menus/manage.ts create mode 100644 src/cli/tui/menus/start.ts create mode 100644 src/cli/tui/menus/stop.ts create mode 100644 src/cli/tui/prompts.ts create mode 100644 src/cli/tui/state.ts create mode 100644 src/cli/types.ts create mode 100644 src/cli/utils/bootstrap.ts create mode 100644 src/cli/utils/config.ts create mode 100644 src/cli/utils/docker.ts create mode 100644 src/cli/utils/env-config.ts create mode 100644 src/cli/utils/formatting.ts create mode 100644 src/cli/utils/output.ts create mode 100644 src/cli/utils/paths.ts create mode 100644 src/cli/utils/process.ts create mode 100644 src/cli/utils/validation.ts create mode 100644 test/unit/cli/cli.integration.spec.ts create mode 100644 test/unit/cli/commands.spec.ts create mode 100644 test/unit/cli/config.spec.ts create mode 100644 test/unit/cli/docker.spec.ts create mode 100644 test/unit/cli/export-command.spec.ts create mode 100644 test/unit/cli/export.spec.ts create mode 100644 test/unit/cli/import.runtime.spec.ts create mode 100644 test/unit/cli/tui.spec.ts create mode 100644 test/unit/cli/update.spec.ts create mode 100644 test/unit/seeds/0000-events.spec.ts diff --git a/docker-compose.yml b/docker-compose.yml index ee7fd472..9264e104 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,7 +77,7 @@ services: default: ipv4_address: 10.10.10.2 - nostream-db: + nostream-db: image: postgres:15 container_name: nostream-db environment: @@ -88,8 +88,9 @@ services: - ${PWD}/.nostr/data:/var/lib/postgresql/data - ${PWD}/.nostr/db-logs:/var/log/postgresql - ${PWD}/postgresql.conf:/postgresql.conf - networks: - default: + networks: + default: + ipv4_address: 10.10.10.3 command: postgres -c 'config_file=/postgresql.conf' restart: always healthcheck: @@ -99,14 +100,15 @@ services: retries: 5 start_period: 360s - nostream-cache: + nostream-cache: image: redis:7.0.5-alpine3.16 container_name: nostream-cache volumes: - cache:/data command: redis-server --loglevel warning --requirepass nostr_ts_relay - networks: - default: + networks: + default: + ipv4_address: 10.10.10.4 restart: always healthcheck: test: [ "CMD", "redis-cli", "ping", "|", "grep", "PONG" ] diff --git a/scripts/clean b/scripts/clean deleted file mode 100755 index 3e8db098..00000000 --- a/scripts/clean +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -PROJECT_ROOT="$(dirname $(readlink -f "${BASH_SOURCE[0]}"))/.." - -$PROJECT_ROOT/scripts/stop_docker - -docker system prune -a - -docker volume prune diff --git a/scripts/print_i2p_hostname b/scripts/print_i2p_hostname deleted file mode 100644 index 4a9b02ed..00000000 --- a/scripts/print_i2p_hostname +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash -set -euo pipefail - -PROJECT_ROOT="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." -KEYS_FILE="${PROJECT_ROOT}/.nostr/i2p/data/nostream.dat" - -if [ ! -f "${KEYS_FILE}" ]; then - echo "I2P destination keys not found. Is the i2pd container running?" - echo "Expected: ${KEYS_FILE}" - exit 1 -fi - -# The .b32.i2p address is derived from a SHA-256 hash of the Destination -# inside nostream.dat, so we cannot compute it portably from the host. -# Query the running i2pd container instead. -echo "I2P destination keys exist at: ${KEYS_FILE}" -echo "" -echo "To find your nostream .b32.i2p address, use one of these methods:" -echo " 1. Open the i2pd web console: http://127.0.0.1:7070/?page=i2p_tunnels" -echo " (published by docker-compose.i2p.yml, bound to 127.0.0.1 only)" -echo " 2. Query the console from inside the container:" -echo " docker exec i2pd wget -qO- 'http://127.0.0.1:7070/?page=i2p_tunnels' \\" -echo " | grep -oE '[a-z2-7]{52}\\.b32\\.i2p' | sort -u" diff --git a/scripts/print_tor_hostname b/scripts/print_tor_hostname deleted file mode 100755 index 09bccb5d..00000000 --- a/scripts/print_tor_hostname +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -PROJECT_ROOT="$(dirname $(readlink -f "${BASH_SOURCE[0]}"))/.." -cat $PROJECT_ROOT/.nostr/tor/data/nostream/hostname diff --git a/scripts/start b/scripts/start deleted file mode 100755 index 296477e0..00000000 --- a/scripts/start +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/bash -PROJECT_ROOT="$(dirname $(readlink -f "${BASH_SOURCE[0]}"))/.." -DOCKER_COMPOSE_FILE="${PROJECT_ROOT}/docker-compose.yml" -NOSTR_CONFIG_DIR="${PROJECT_ROOT}/.nostr" -SETTINGS_FILE="${NOSTR_CONFIG_DIR}/settings.yaml" -DEFAULT_SETTINGS_FILE="${PROJECT_ROOT}/resources/default-settings.yaml" -CURRENT_DIR=$(pwd) - -if [[ ${CURRENT_DIR} =~ /scripts$ ]]; then - echo "Please run this script from the Nostream root folder, not the scripts directory." - echo "To do this, change up one directory, and then run the following command:" - echo "./scripts/start" - exit 1 -fi - - -if [ "$EUID" -eq 0 ] - then echo "Error: Nostream should not be run as root." - exit 1 -fi - -# ── DNS Pre-flight Check ───────────────────────────────────────────── -YELLOW=$'\033[0;33m' -BOLD_YELLOW=$'\033[1;33m' -NC=$'\033[0m' -DNS_TEST_URL="https://dl-cdn.alpinelinux.org" -DNS_MAX_RETRIES=3 -DNS_OK=false -BACKOFF=2 - -echo "Checking Docker DNS connectivity..." -for i in $(seq 1 $DNS_MAX_RETRIES); do - printf " [Attempt $i/$DNS_MAX_RETRIES] Testing resolution... " - if docker run --rm alpine wget --spider --timeout=5 "$DNS_TEST_URL" > /dev/null 2>&1; then - echo "Success" - DNS_OK=true - break - else - echo "Failed" - fi - [ "$i" -lt "$DNS_MAX_RETRIES" ] && sleep $BACKOFF && BACKOFF=$((BACKOFF * 2)) -done - -if [ "$DNS_OK" = false ]; then - cat <&2 - -${BOLD_YELLOW} WARNING: Docker DNS resolution failed after $DNS_MAX_RETRIES attempts.${NC} -${YELLOW} Containers cannot resolve external domains (e.g. dl-cdn.alpinelinux.org). - This is commonly caused by a DNS bridge conflict with systemd-resolved. - - Suggested fixes: - 1. Add DNS to /etc/docker/daemon.json: - { "dns": ["8.8.8.8", "8.8.4.4"] } - 2. Then run sudo systemctl restart docker - - The build will continue, but may fail during package installation.${NC} - -EOF -fi - -if [[ ! -d "${NOSTR_CONFIG_DIR}" ]]; then - echo "Creating folder ${NOSTR_CONFIG_DIR}" - mkdir -p "${NOSTR_CONFIG_DIR}" -fi - -if [[ ! -f "${SETTINGS_FILE}" ]]; then - echo "Copying ${DEFAULT_SETTINGS_FILE} to ${SETTINGS_FILE}" - cp "${DEFAULT_SETTINGS_FILE}" "${SETTINGS_FILE}" -fi - -docker compose \ - -f $DOCKER_COMPOSE_FILE \ - up --build --remove-orphans $@ diff --git a/scripts/start_with_i2p b/scripts/start_with_i2p deleted file mode 100644 index cc458a3d..00000000 --- a/scripts/start_with_i2p +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash -set -euo pipefail - -PROJECT_ROOT="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." -DOCKER_COMPOSE_FILE="${PROJECT_ROOT}/docker-compose.yml" -DOCKER_COMPOSE_I2P_FILE="${PROJECT_ROOT}/docker-compose.i2p.yml" -I2P_DATA_DIR="${PROJECT_ROOT}/.nostr/i2p/data" -NOSTR_CONFIG_DIR="${PROJECT_ROOT}/.nostr" -SETTINGS_FILE="${NOSTR_CONFIG_DIR}/settings.yaml" -DEFAULT_SETTINGS_FILE="${PROJECT_ROOT}/resources/default-settings.yaml" -CURRENT_DIR="$(pwd)" - -if [[ ${CURRENT_DIR} =~ /scripts$ ]]; then - echo "Please run this script from the Nostream root folder, not the scripts directory." - echo "To do this, change up one directory, and then run the following command:" - echo "./scripts/start_with_i2p" - exit 1 -fi - -if [ "$EUID" -eq 0 ]; then - echo "Error: Nostream should not be run as root." - exit 1 -fi - -if [[ ! -d "${NOSTR_CONFIG_DIR}" ]]; then - echo "Creating folder ${NOSTR_CONFIG_DIR}" - mkdir -p "${NOSTR_CONFIG_DIR}" -fi - -if [[ ! -f "${SETTINGS_FILE}" ]]; then - echo "Copying ${DEFAULT_SETTINGS_FILE} to ${SETTINGS_FILE}" - cp "${DEFAULT_SETTINGS_FILE}" "${SETTINGS_FILE}" -fi - -mkdir -p "${I2P_DATA_DIR}" - -docker compose \ - -f "${DOCKER_COMPOSE_FILE}" \ - -f "${DOCKER_COMPOSE_I2P_FILE}" \ - up --build --remove-orphans "$@" diff --git a/scripts/start_with_nginx b/scripts/start_with_nginx deleted file mode 100755 index 45a2bed9..00000000 --- a/scripts/start_with_nginx +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/bash -PROJECT_ROOT="$(dirname $(readlink -f "${BASH_SOURCE[0]}"))/.." -DOCKER_COMPOSE_FILE="${PROJECT_ROOT}/docker-compose.yml" -DOCKER_COMPOSE_NGINX_FILE="${PROJECT_ROOT}/docker-compose.nginx.yml" -NGINX_CONF_DIR="${PROJECT_ROOT}/nginx/conf.d" -NGINX_TEMPLATE="${NGINX_CONF_DIR}/nostream.conf.template" -NGINX_CONF="${NGINX_CONF_DIR}/nostream.conf" -NOSTR_CONFIG_DIR="${PROJECT_ROOT}/.nostr" -SETTINGS_FILE="${NOSTR_CONFIG_DIR}/settings.yaml" -DEFAULT_SETTINGS_FILE="${PROJECT_ROOT}/resources/default-settings.yaml" -CURRENT_DIR=$(pwd) - -if [[ ${CURRENT_DIR} =~ /scripts$ ]]; then - echo "Please run this script from the Nostream root folder, not the scripts directory." - echo "To do this, change up one directory, and then run the following command:" - echo "./scripts/start_with_nginx" - exit 1 -fi - -if [ "$EUID" -eq 0 ] - then echo "Error: Nostream should not be run as root." - exit 1 -fi - -if [[ -z "${RELAY_DOMAIN}" ]]; then - echo "Error: RELAY_DOMAIN environment variable is not set." - echo "Usage: RELAY_DOMAIN=relay.example.com CERTBOT_EMAIL=you@example.com ./scripts/start_with_nginx" - exit 1 -fi - -FQDN_REGEX='^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$' -if [[ ! "${RELAY_DOMAIN}" =~ ${FQDN_REGEX} ]]; then - echo "Error: RELAY_DOMAIN must be a valid fully-qualified domain name." - echo "Usage: RELAY_DOMAIN=relay.example.com CERTBOT_EMAIL=you@example.com ./scripts/start_with_nginx" - exit 1 -fi - -if [[ -z "${CERTBOT_EMAIL}" ]]; then - echo "Error: CERTBOT_EMAIL environment variable is not set." - echo "Usage: RELAY_DOMAIN=relay.example.com CERTBOT_EMAIL=you@example.com ./scripts/start_with_nginx" - exit 1 -fi - -if [[ ! -d "${NOSTR_CONFIG_DIR}" ]]; then - echo "Creating folder ${NOSTR_CONFIG_DIR}" - mkdir -p "${NOSTR_CONFIG_DIR}" -fi - -if [[ ! -f "${SETTINGS_FILE}" ]]; then - echo "Copying ${DEFAULT_SETTINGS_FILE} to ${SETTINGS_FILE}" - cp "${DEFAULT_SETTINGS_FILE}" "${SETTINGS_FILE}" -fi - -# Generate nginx config from template -echo "Generating nginx config for domain: ${RELAY_DOMAIN}" -sed "s/\${RELAY_DOMAIN}/${RELAY_DOMAIN}/g" "${NGINX_TEMPLATE}" > "${NGINX_CONF}" - -# Generate a temporary self-signed cert if no real cert exists yet. -# This lets nginx boot so it can serve the ACME challenge for certbot -# to obtain the real Let's Encrypt certificate. -SSL_CERT_DIR="${PROJECT_ROOT}/nginx/ssl/live/${RELAY_DOMAIN}" -if [[ ! -f "${SSL_CERT_DIR}/fullchain.pem" ]]; then - echo "No SSL certificate found. Generating a temporary self-signed certificate..." - mkdir -p "${SSL_CERT_DIR}" - if ! openssl req -x509 -nodes -newkey rsa:2048 \ - -days 1 \ - -keyout "${SSL_CERT_DIR}/privkey.pem" \ - -out "${SSL_CERT_DIR}/fullchain.pem" \ - -subj "/CN=${RELAY_DOMAIN}" 2>/dev/null; then - echo "Error: Failed to generate self-signed certificate. Is openssl installed?" - exit 1 - fi -fi - -# Ensure compose uses the project root for volume mounts -cd "${PROJECT_ROOT}" - -docker compose \ - -f "${DOCKER_COMPOSE_FILE}" \ - -f "${DOCKER_COMPOSE_NGINX_FILE}" \ - up --build --remove-orphans "$@" diff --git a/scripts/start_with_tor b/scripts/start_with_tor deleted file mode 100755 index cb7f52b2..00000000 --- a/scripts/start_with_tor +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash -PROJECT_ROOT="$(dirname $(readlink -f "${BASH_SOURCE[0]}"))/.." -DOCKER_COMPOSE_FILE="${PROJECT_ROOT}/docker-compose.yml" -DOCKER_COMPOSE_TOR_FILE="${PROJECT_ROOT}/docker-compose.tor.yml" -TOR_DATA_DIR="$PROJECT_ROOT/.nostr/tor/data" -NOSTR_CONFIG_DIR="${PROJECT_ROOT}/.nostr" -SETTINGS_FILE="${NOSTR_CONFIG_DIR}/settings.yaml" -DEFAULT_SETTINGS_FILE="${PROJECT_ROOT}/resources/default-settings.yaml" -CURRENT_DIR=$(pwd) - -if [[ ${CURRENT_DIR} =~ /scripts$ ]]; then - echo "Please run this script from the Nostream root folder, not the scripts directory." - echo "To do this, change up one directory, and then run the following command:" - echo "./scripts/start" - exit 1 -fi - -if [ "$EUID" -eq 0 ] - then echo "Error: Nostream should not be run as root." - exit 1 -fi - -if [[ ! -d "${NOSTR_CONFIG_DIR}" ]]; then - echo "Creating folder ${NOSTR_CONFIG_DIR}" - mkdir -p "${NOSTR_CONFIG_DIR}" -fi - -if [[ ! -f "${SETTINGS_FILE}" ]]; then - echo "Copying ${DEFAULT_SETTINGS_FILE} to ${SETTINGS_FILE}" - cp "${DEFAULT_SETTINGS_FILE}" "${SETTINGS_FILE}" -fi - -mkdir -p $TOR_DATA_DIR - -docker compose \ - -f $DOCKER_COMPOSE_FILE \ - -f $DOCKER_COMPOSE_TOR_FILE \ - up --build --remove-orphans $@ diff --git a/scripts/stop b/scripts/stop deleted file mode 100755 index 788cf020..00000000 --- a/scripts/stop +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -set -euo pipefail - -PROJECT_ROOT="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." -DOCKER_COMPOSE_FILE="${PROJECT_ROOT}/docker-compose.yml" -DOCKER_COMPOSE_TOR_FILE="${PROJECT_ROOT}/docker-compose.tor.yml" -DOCKER_COMPOSE_I2P_FILE="${PROJECT_ROOT}/docker-compose.i2p.yml" -DOCKER_COMPOSE_LOCAL_FILE="${PROJECT_ROOT}/docker-compose.local.yml" - -docker compose \ - -f "${DOCKER_COMPOSE_FILE}" \ - -f "${DOCKER_COMPOSE_TOR_FILE}" \ - -f "${DOCKER_COMPOSE_I2P_FILE}" \ - -f "${DOCKER_COMPOSE_LOCAL_FILE}" \ - down "$@" diff --git a/scripts/update b/scripts/update deleted file mode 100755 index 54fe76fd..00000000 --- a/scripts/update +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash -PROJECT_ROOT="$(dirname $(readlink -f "${BASH_SOURCE[0]}"))/.." -SCRIPTS_DIR="${PROJECT_ROOT}/scripts" - -$SCRIPTS_DIR/stop - -git stash -u - -git pull - -git stash pop - -$SCRIPTS_DIR/start diff --git a/scripts/verify-cli-build.js b/scripts/verify-cli-build.js new file mode 100644 index 00000000..566359cb --- /dev/null +++ b/scripts/verify-cli-build.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node +const fs = require('fs') +const path = require('path') +const { spawnSync } = require('child_process') + +const pkgPath = path.resolve(__dirname, '..', 'package.json') +const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) +const relBin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.nostream + +if (!relBin) { + console.error('package.json is missing bin.nostream') + process.exit(1) +} + +const binPath = path.resolve(__dirname, '..', relBin) +if (!fs.existsSync(binPath)) { + console.error(`Built CLI entrypoint not found: ${binPath}`) + process.exit(1) +} + +const result = spawnSync('node', [binPath, '--help'], { + cwd: path.resolve(__dirname, '..'), + env: process.env, + encoding: 'utf-8', +}) + +if (result.status !== 0) { + console.error(`Built CLI help check failed (exit ${result.status ?? 1})`) + if (result.stdout) { + process.stderr.write(result.stdout) + } + if (result.stderr) { + process.stderr.write(result.stderr) + } + process.exit(result.status ?? 1) +} + +if (!result.stdout.includes('Usage:')) { + console.error('Built CLI help output did not contain Usage:') + process.exit(1) +} + +console.log(`Verified CLI build entrypoint: ${relBin}`) diff --git a/seeds/0000-events.js b/seeds/0000-events.js index 4544dbed..1e94490e 100644 --- a/seeds/0000-events.js +++ b/seeds/0000-events.js @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-var-requires */ +const secp256k1 = require('@noble/secp256k1') + const NAMESPACE = 'c646b451-db73-47fb-9a70-ea24ce8a225a' +const SYNTHETIC_SEED_PRIVATE_KEY = '1'.repeat(64) function isReplaceableEvent(kind) { - return kind === 0 - || kind === 3 - || kind === 41 - || (kind >= 10000 && kind < 20000) + return kind === 0 || kind === 3 || kind === 41 || (kind >= 10000 && kind < 20000) } function isParameterizedReplaceableEvent(kind) { @@ -27,12 +27,85 @@ function getEventDeduplication(event) { return null } +function getRequestedSeedCount() { + const rawValue = process.env.NOSTREAM_SEED_COUNT + + if (typeof rawValue !== 'string' || rawValue.trim() === '') { + return undefined + } + + if (!/^\d+$/.test(rawValue.trim())) { + throw new Error(`Invalid NOSTREAM_SEED_COUNT: ${rawValue}. Expected a positive integer.`) + } + + const parsed = Number(rawValue) + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid NOSTREAM_SEED_COUNT: ${rawValue}. Expected a positive integer.`) + } + + return parsed +} + +function serializeEvent(event) { + return [0, event.pubkey, event.created_at, event.kind, event.tags, event.content] +} + +async function identifyEvent(event) { + const idBytes = await secp256k1.utils.sha256(Buffer.from(JSON.stringify(serializeEvent(event)))) + return { + ...event, + id: Buffer.from(idBytes).toString('hex'), + } +} + +async function signEvent(event, privateKey) { + const signature = await secp256k1.schnorr.sign(event.id, privateKey) + + return { + ...event, + sig: Buffer.from(signature).toString('hex'), + } +} + +const syntheticSeedPubkey = secp256k1.utils.bytesToHex(secp256k1.getPublicKey(SYNTHETIC_SEED_PRIVATE_KEY, true).subarray(1)) + +async function createSyntheticEvent(baseEvent, index) { + const unsignedEvent = { + pubkey: syntheticSeedPubkey, + created_at: baseEvent.created_at + index, + kind: baseEvent.kind, + tags: baseEvent.tags, + content: `${baseEvent.content} [seed:${index + 1}]`, + } + + const identifiedEvent = await identifyEvent(unsignedEvent) + return signEvent(identifiedEvent, SYNTHETIC_SEED_PRIVATE_KEY) +} + +async function expandSeedEvents(events, count) { + if (!count) { + return events + } + + const expanded = [] + for (let index = 0; index < count; index += 1) { + const baseEvent = events[index % events.length] + expanded.push(await createSyntheticEvent(baseEvent, index)) + } + + return expanded +} + exports.seed = async function (knex) { await knex('events').del() const { v5: uuidv5 } = require('uuid') - const eventRows = require('./events.json').reduce((result, event) => { + const sourceEvents = require('./events.json') + const requestedCount = getRequestedSeedCount() + const events = await expandSeedEvents(sourceEvents, requestedCount) + + const eventRows = events.reduce((result, event) => { result.push({ id: uuidv5(event.id, NAMESPACE), event_id: Buffer.from(event.id, 'hex'), diff --git a/src/clean-db.ts b/src/clean-db.ts index 0d176e97..ababf38c 100644 --- a/src/clean-db.ts +++ b/src/clean-db.ts @@ -13,7 +13,7 @@ type CleanDbOptions = { } const HELP_TEXT = [ - 'Usage: npm run clean-db -- [options]', + 'Usage: nostream dev db:clean [options]', '', 'Options:', ' --all Delete all events.', @@ -24,10 +24,10 @@ const HELP_TEXT = [ ' --help Show this help message.', '', 'Examples:', - ' npm run clean-db -- --all --dry-run', - ' npm run clean-db -- --all --force', - ' npm run clean-db -- --older-than=30 --force', - ' npm run clean-db -- --older-than=30 --kinds=1,7,4 --dry-run', + ' nostream dev db:clean --all --dry-run', + ' nostream dev db:clean --all --force', + ' nostream dev db:clean --older-than=30 --force', + ' nostream dev db:clean --older-than=30 --kinds=1,7,4 --dry-run', ].join('\n') const getOptionValue = (arg: string, args: string[], index: number): [string, number] => { diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts new file mode 100644 index 00000000..cf7214de --- /dev/null +++ b/src/cli/commands/config.ts @@ -0,0 +1,232 @@ +import ora from 'ora' +import yaml from 'js-yaml' + +import { + getByPath, + loadDefaults, + loadMergedSettings, + loadUserSettings, + parseTypedValue, + saveSettings, + setByPath, + validatePathAgainstDefaults, + validateSettings, +} from '../utils/config' +import { + isSecretEnvKey, + isSupportedEnvKey, + maskSecretValue, + readEnvValues, + upsertEnvValue, + validateEnvPair, + validateEnvValues, +} from '../utils/env-config' +import { logError, logInfo } from '../utils/output' +import { runStart } from './start' +import { runStop } from './stop' + +type ValueType = 'inferred' | 'json' + +const serialize = (value: unknown): string => { + if (typeof value === 'bigint') { + return value.toString() + } + + if (typeof value === 'string') { + return value + } + + if (value === undefined) { + return 'undefined' + } + + return yaml.dump(value, { lineWidth: 120 }).trimEnd() +} + +const formatLabel = (key: string): string => { + return key + .split(/[_\-.]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +const restartRelay = async (): Promise => { + const spinner = ora('Restarting relay...').start() + + const stopCode = await runStop({ all: true }, []) + if (stopCode !== 0) { + spinner.fail('Restart failed while stopping relay') + return stopCode + } + + const startCode = await runStart({}, []) + if (startCode !== 0) { + spinner.fail('Restart failed while starting relay') + return startCode + } + + spinner.succeed('Relay restarted') + return 0 +} + +export const runConfigList = async (): Promise => { + const settings = loadMergedSettings() + logInfo(yaml.dump(settings, { lineWidth: 120 })) + return 0 +} + +export const runConfigGet = async (path: string): Promise => { + const settings = loadMergedSettings() as unknown as Record + const value = getByPath(settings, path) + + if (value === undefined) { + logError(`Path not found: ${path}`) + return 1 + } + + logInfo(serialize(value)) + return 0 +} + +export const runConfigSet = async ( + path: string, + rawValue: string, + options: { + restart?: boolean + validate?: boolean + valueType?: ValueType + } = {}, +): Promise => { + const valueType = options.valueType ?? 'inferred' + + const pathIssues = validatePathAgainstDefaults(path) + if (pathIssues.length > 0) { + logError(pathIssues[0].message) + return 1 + } + + const settings = loadUserSettings() as unknown as Record + const next = setByPath(settings, path, parseTypedValue(rawValue, valueType)) + + if (options.validate !== false) { + const merged = loadMergedSettings() as unknown as Record + const mergedNext = setByPath(merged, path, getByPath(next, path)) + const validationIssues = validateSettings(mergedNext as any) + + if (validationIssues.length > 0) { + logError('Config update rejected by validation:') + for (const issue of validationIssues) { + logError(`- ${issue.path}: ${issue.message}`) + } + + return 1 + } + } + + saveSettings(next as any) + + logInfo(`Updated ${path}`) + + if (options.restart) { + return restartRelay() + } + + return 0 +} + +export const runConfigValidate = async (): Promise => { + const settings = loadMergedSettings() + const issues = validateSettings(settings) + + if (issues.length === 0) { + logInfo('Settings are valid') + return 0 + } + + logError('Settings validation failed:') + for (const issue of issues) { + logError(`- ${issue.path}: ${issue.message}`) + } + + return 1 +} + +export const runConfigEnvList = async (options: { showSecrets?: boolean } = {}): Promise => { + const values = readEnvValues() + const entries = Object.entries(values).sort(([a], [b]) => a.localeCompare(b)) + + if (entries.length === 0) { + logInfo('No .env entries found') + return 0 + } + + for (const [key, value] of entries) { + const displayValue = options.showSecrets || !isSecretEnvKey(key) ? value : maskSecretValue(value) + logInfo(`${key}=${displayValue}`) + } + + return 0 +} + +export const runConfigEnvGet = async (key: string, options: { showSecrets?: boolean } = {}): Promise => { + const normalizedKey = key.trim() + + if (!isSupportedEnvKey(normalizedKey)) { + logError(`Unsupported env key: ${normalizedKey}`) + return 1 + } + + const values = readEnvValues() + const value = values[normalizedKey] + + if (value === undefined) { + logError(`Env key not set: ${normalizedKey}`) + return 1 + } + + const displayValue = options.showSecrets || !isSecretEnvKey(normalizedKey) ? value : maskSecretValue(value) + logInfo(displayValue) + return 0 +} + +export const runConfigEnvSet = async (key: string, value: string): Promise => { + const normalizedKey = key.trim() + + if (!isSupportedEnvKey(normalizedKey)) { + logError(`Unsupported env key: ${normalizedKey}`) + return 1 + } + + const issue = validateEnvPair(normalizedKey, value) + if (issue) { + logError(issue) + return 1 + } + + upsertEnvValue(normalizedKey, value) + logInfo(`Updated ${normalizedKey}`) + return 0 +} + +export const runConfigEnvValidate = async (): Promise => { + const values = readEnvValues() + const issues = validateEnvValues(values) + + if (issues.length === 0) { + logInfo('Environment settings are valid') + return 0 + } + + logError('Environment validation failed:') + for (const issue of issues) { + logError(`- ${formatLabel(issue.path)} (${issue.path}): ${issue.message}`) + } + + return 1 +} + +export const getConfigTopLevelCategories = (): string[] => { + const defaults = loadDefaults() as unknown as Record + return Object.keys(defaults) +} diff --git a/src/cli/commands/dev.ts b/src/cli/commands/dev.ts new file mode 100644 index 00000000..78e34807 --- /dev/null +++ b/src/cli/commands/dev.ts @@ -0,0 +1,139 @@ +import { confirm, isCancel, cancel } from '@clack/prompts' +import ora from 'ora' + +import { runCleanDb } from '../../clean-db' +import { runCommand } from '../utils/process' +import { runStop } from './stop' + +type DevOptions = { + yes?: boolean +} + +const ensureConfirmed = async (message: string, yes?: boolean): Promise => { + if (yes) { + return true + } + + if (!process.stdin.isTTY) { + throw new Error('Interactive confirmation is unavailable. Re-run with --yes.') + } + + const answer = await confirm({ message, initialValue: false }) + if (isCancel(answer)) { + cancel('Operation cancelled') + return false + } + + return answer +} + +const runWithSpinner = async ( + loadingText: string, + successText: string, + failureText: string, + action: () => Promise, +): Promise => { + const spinner = ora(loadingText).start() + + try { + const code = await action() + if (code === 0) { + spinner.succeed(successText) + } else { + spinner.fail(failureText) + } + + return code + } catch (error) { + spinner.fail(failureText) + throw error + } +} + +export const runDevDbClean = async (rawArgs: string[], options: DevOptions = {}): Promise => { + if (rawArgs.length === 0) { + const confirmed = await ensureConfirmed('Delete all events from the database?', options.yes) + if (!confirmed) { + return 1 + } + + return runWithSpinner('Cleaning database...', 'Database clean completed', 'Database clean failed', () => + runCleanDb(['--all', '--force']), + ) + } + + return runWithSpinner('Cleaning database...', 'Database clean completed', 'Database clean failed', () => + runCleanDb(rawArgs), + ) +} + +export const runDevDbReset = async (options: DevOptions): Promise => { + const confirmed = await ensureConfirmed('Reset database and rerun migrations?', options.yes) + if (!confirmed) { + return 1 + } + + const spinner = ora('Resetting database (rollback)...').start() + + let code = await runCommand('npm', ['run', 'db:migrate:rollback', '--', '--all']) + if (code !== 0) { + spinner.fail('Database reset failed during rollback') + return code + } + + spinner.text = 'Resetting database (migrate)...' + code = await runCommand('npm', ['run', 'db:migrate']) + if (code === 0) { + spinner.succeed('Database reset completed') + } else { + spinner.fail('Database reset failed during migrate') + } + + return code +} + +export const runDevSeedRelay = async (): Promise => { + return runWithSpinner('Seeding relay data...', 'Relay seed completed', 'Relay seed failed', () => + runCommand('npm', ['run', 'db:seed']), + ) +} + +export const runDevDockerClean = async (options: DevOptions): Promise => { + const confirmed = await ensureConfirmed('Run docker system prune and docker volume prune?', options.yes) + if (!confirmed) { + return 1 + } + + let code = await runStop({ all: true }, []) + if (code !== 0) { + return code + } + + code = await runCommand('docker', ['system', 'prune', '-a', '-f']) + if (code !== 0) { + return code + } + + return runCommand('docker', ['volume', 'prune', '-f']) +} + +export const runDevTestUnit = async (): Promise => { + return runWithSpinner('Running unit tests...', 'Unit tests completed', 'Unit tests failed', () => + runCommand('npm', ['run', 'test:unit']), + ) +} + +export const runDevTestCli = async (): Promise => { + return runWithSpinner('Running CLI tests...', 'CLI tests completed', 'CLI tests failed', () => + runCommand('npm', ['run', 'test:cli']), + ) +} + +export const runDevTestIntegration = async (): Promise => { + return runWithSpinner( + 'Running integration tests...', + 'Integration tests completed', + 'Integration tests failed', + () => runCommand('npm', ['run', 'test:integration']), + ) +} diff --git a/src/cli/commands/export.ts b/src/cli/commands/export.ts new file mode 100644 index 00000000..46b73c53 --- /dev/null +++ b/src/cli/commands/export.ts @@ -0,0 +1,54 @@ +import { runExportEvents } from '../../scripts/export-events' + +type ExportFormat = 'jsonl' | 'json' +type CompressionFormat = 'gzip' | 'gz' | 'xz' + +type ExportOptions = { + output?: string + format?: ExportFormat + compress?: boolean + compressionFormat?: CompressionFormat +} + +export const runExport = async (options: ExportOptions, rawArgs: string[]): Promise => { + const args: string[] = [] + + if (options.output) { + args.push(options.output) + } + + if (options.compress) { + args.push('--compress') + } + + if (options.compressionFormat) { + args.push('--format', options.compressionFormat) + } + + let skipNext = false + for (const arg of rawArgs) { + if (skipNext) { + skipNext = false + continue + } + + if (arg === '--format') { + skipNext = true + continue + } + + if (arg.startsWith('--format=')) { + continue + } + + if (arg === '--compress' || arg === '-z') { + continue + } + + args.push(arg) + } + + return runExportEvents(args, { + format: options.format, + }) +} diff --git a/src/cli/commands/import.ts b/src/cli/commands/import.ts new file mode 100644 index 00000000..53eca086 --- /dev/null +++ b/src/cli/commands/import.ts @@ -0,0 +1,20 @@ +import { runImportEvents } from '../../import-events' + +type ImportOptions = { + file?: string + batchSize?: number +} + +export const runImport = async (options: ImportOptions, rawArgs: string[]): Promise => { + const args: string[] = [] + + if (options.file) { + args.push(options.file) + } + + if (typeof options.batchSize === 'number') { + args.push('--batch-size', String(options.batchSize)) + } + + return runImportEvents([...args, ...rawArgs]) +} diff --git a/src/cli/commands/info.ts b/src/cli/commands/info.ts new file mode 100644 index 00000000..bae2a6d7 --- /dev/null +++ b/src/cli/commands/info.ts @@ -0,0 +1,163 @@ +import fs from 'fs' + +import packageJson from '../../../package.json' +import { getMasterDbClient } from '../../database/client' +import { loadMergedSettings } from '../utils/config' +import { logError, logInfo } from '../utils/output' +import { getOnionKeyPath, getTorHostnamePath } from '../utils/bootstrap' +import { getProjectPath } from '../utils/paths' +import { runCommandWithOutput } from '../utils/process' + +type InfoOptions = { + torHostname?: boolean + i2pHostname?: boolean +} + +const getEventCount = async (): Promise => { + const db = getMasterDbClient() + try { + const result = await db('events').whereNull('deleted_at').count<{ count: string | number }>('* as count').first() + return Number(result?.count ?? 0) + } catch { + return null + } finally { + await db.destroy() + } +} + +const getRelayUptimeSeconds = async (): Promise => { + const idResult = await runCommandWithOutput('docker', ['compose', 'ps', '-q', 'nostream']) + if (idResult.code !== 0) { + return null + } + + const containerId = idResult.stdout.trim() + if (!containerId) { + return null + } + + const startedAtResult = await runCommandWithOutput('docker', ['inspect', '--format', '{{.State.StartedAt}}', containerId]) + if (startedAtResult.code !== 0) { + return null + } + + const startedAtRaw = startedAtResult.stdout.trim() + const startedAtMs = Date.parse(startedAtRaw) + if (!Number.isFinite(startedAtMs)) { + return null + } + + const seconds = Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)) + return seconds +} + +const formatUptime = (uptimeSeconds: number | null): string => { + if (uptimeSeconds === null) { + return 'unavailable' + } + + const days = Math.floor(uptimeSeconds / 86400) + const hours = Math.floor((uptimeSeconds % 86400) / 3600) + const minutes = Math.floor((uptimeSeconds % 3600) / 60) + const seconds = uptimeSeconds % 60 + + const segments = [] + if (days > 0) { + segments.push(`${days}d`) + } + if (hours > 0 || days > 0) { + segments.push(`${hours}h`) + } + if (minutes > 0 || hours > 0 || days > 0) { + segments.push(`${minutes}m`) + } + segments.push(`${seconds}s`) + return segments.join(' ') +} + +const getInfoPayload = async () => { + const settings = loadMergedSettings() + const torHostnamePath = getTorHostnamePath() + const torHostname = fs.existsSync(torHostnamePath) ? fs.readFileSync(torHostnamePath, 'utf-8').trim() : null + const [eventCount, uptimeSeconds] = await Promise.all([getEventCount(), getRelayUptimeSeconds()]) + + return { + version: packageJson.version, + relay: { + name: settings.info?.name, + url: settings.info?.relay_url, + pubkey: settings.info?.pubkey, + paymentsEnabled: settings.payments?.enabled ?? false, + paymentProcessor: settings.payments?.processor ?? null, + }, + tor: { + hostname: torHostname, + onionPrivateKeyPath: getOnionKeyPath(), + }, + runtime: { + eventCount, + uptimeSeconds, + }, + } +} + +export const runInfo = async (options: InfoOptions): Promise => { + const payload = await getInfoPayload() + + if (options.torHostname) { + if (payload.tor.hostname) { + logInfo(payload.tor.hostname) + return 0 + } + + logError('Tor hostname not found. Start with `nostream start --tor` first.') + return 1 + } + + if (options.i2pHostname) { + const keysFile = getProjectPath('.nostr', 'i2p', 'data', 'nostream.dat') + + if (!fs.existsSync(keysFile)) { + logError('I2P destination keys not found. Is the i2pd container running?') + logError(`Expected: ${keysFile}`) + return 1 + } + + const result = await runCommandWithOutput('docker', [ + 'exec', + 'i2pd', + 'wget', + '-qO-', + 'http://127.0.0.1:7070/?page=i2p_tunnels', + ]) + + const matches = new Set((`${result.stdout}\n${result.stderr}`).match(/[a-z2-7]{52}\.b32\.i2p/g) ?? []) + if (matches.size > 0) { + for (const hostname of matches) { + logInfo(hostname) + } + return 0 + } + + logInfo(`I2P destination keys exist at: ${keysFile}`) + logInfo('') + logInfo('To find your nostream .b32.i2p address, use one of these methods:') + logInfo(' 1. Open the i2pd web console: http://127.0.0.1:7070/?page=i2p_tunnels') + logInfo(' (published by docker-compose.i2p.yml, bound to 127.0.0.1 only)') + logInfo(' 2. Query the console from inside the container:') + logInfo(" docker exec i2pd wget -qO- 'http://127.0.0.1:7070/?page=i2p_tunnels' \\") + logInfo(" | grep -oE '[a-z2-7]{52}\\\\.b32\\\\.i2p' | sort -u") + return 0 + } + + logInfo(`Nostream v${payload.version}`) + logInfo(`Relay: ${payload.relay.name ?? 'n/a'} (${payload.relay.url ?? 'n/a'})`) + logInfo(`Pubkey: ${payload.relay.pubkey ?? 'n/a'}`) + logInfo(`Payments: ${payload.relay.paymentsEnabled ? `enabled (${payload.relay.paymentProcessor})` : 'disabled'}`) + logInfo(`Tor hostname: ${payload.tor.hostname ?? 'not found'}`) + logInfo(`Onion key path: ${payload.tor.onionPrivateKeyPath}`) + logInfo(`Events: ${payload.runtime.eventCount ?? 'unavailable'}`) + logInfo(`Uptime: ${formatUptime(payload.runtime.uptimeSeconds)}`) + + return 0 +} diff --git a/src/cli/commands/seed.ts b/src/cli/commands/seed.ts new file mode 100644 index 00000000..c8880d4b --- /dev/null +++ b/src/cli/commands/seed.ts @@ -0,0 +1,33 @@ +import ora from 'ora' + +import { runCommand } from '../utils/process' + +type SeedOptions = { + count?: number +} + +export const runSeed = async (options: SeedOptions): Promise => { + if (options.count !== undefined) { + if (!Number.isSafeInteger(options.count) || options.count <= 0) { + throw new Error('--count must be a positive integer') + } + } + + const spinner = ora('Seeding relay data...').start() + + const code = await runCommand('npm', ['run', 'db:seed'], { + env: options.count ? { NOSTREAM_SEED_COUNT: String(options.count) } : undefined, + }) + + if (code === 0) { + if (options.count) { + spinner.succeed(`Seed completed with ${options.count} events requested`) + } else { + spinner.succeed('Seed completed') + } + } else { + spinner.fail('Seed failed') + } + + return code +} diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts new file mode 100644 index 00000000..f0355120 --- /dev/null +++ b/src/cli/commands/setup.ts @@ -0,0 +1,84 @@ +import fs from 'fs' +import { intro, outro, confirm, text, isCancel, cancel } from '@clack/prompts' + +import { ensureConfigBootstrap } from '../utils/bootstrap' +import { getProjectPath } from '../utils/paths' +import { runStart } from './start' + +type SetupOptions = { + yes?: boolean + start?: boolean +} + +const ensureEnvFile = async (assumeYes: boolean): Promise => { + const envPath = getProjectPath('.env') + const envExamplePath = getProjectPath('.env.example') + + if (fs.existsSync(envPath)) { + return + } + + if (fs.existsSync(envExamplePath)) { + fs.copyFileSync(envExamplePath, envPath) + } else { + fs.writeFileSync(envPath, '', 'utf-8') + } + + const current = fs.readFileSync(envPath, 'utf-8') + if (current.includes('SECRET=')) { + return + } + + let secret = process.env.SECRET + + if (!assumeYes && process.stdin.isTTY) { + const value = await text({ + message: 'SECRET env var value (hex recommended)', + placeholder: 'openssl rand -hex 128', + defaultValue: secret, + validate: (input) => (input.trim() ? undefined : 'SECRET is required'), + }) + + if (isCancel(value)) { + cancel('Setup cancelled') + process.exitCode = 1 + return + } + + secret = value + } + + if (!secret) { + throw new Error('SECRET is required. Set SECRET env var or run setup interactively.') + } + + fs.appendFileSync(envPath, `\nSECRET=${secret}\n`, 'utf-8') +} + +export const runSetup = async (options: SetupOptions): Promise => { + intro('Nostream setup') + + ensureConfigBootstrap() + await ensureEnvFile(Boolean(options.yes)) + + let shouldStart = Boolean(options.start) + + if (!options.yes && !options.start && process.stdin.isTTY) { + const answer = await confirm({ message: 'Start relay now?', initialValue: true }) + if (isCancel(answer)) { + cancel('Setup cancelled') + return 1 + } + + shouldStart = answer + } + + if (shouldStart) { + const code = await runStart({}, []) + outro(code === 0 ? 'Setup complete' : 'Setup finished with errors') + return code + } + + outro('Setup complete') + return 0 +} diff --git a/src/cli/commands/start.ts b/src/cli/commands/start.ts new file mode 100644 index 00000000..c10df3a4 --- /dev/null +++ b/src/cli/commands/start.ts @@ -0,0 +1,141 @@ +import fs from 'fs' +import { join } from 'path' +import ora from 'ora' + +import { StartOptions } from '../types' +import { ensureConfigBootstrap, ensureI2PDataDir, ensureNotRoot, ensureTorDataDir } from '../utils/bootstrap' +import { createPortOverrideComposeFile, runDockerCompose } from '../utils/docker' +import { logStep } from '../utils/output' +import { getProjectPath } from '../utils/paths' +import { runCommand } from '../utils/process' + +const FQDN_REGEX = + /^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/ + +const ensureNginxBootstrap = async (): Promise => { + const relayDomain = process.env.RELAY_DOMAIN?.trim() + if (!relayDomain) { + throw new Error( + 'RELAY_DOMAIN environment variable is required when using --nginx (example: RELAY_DOMAIN=relay.example.com).', + ) + } + + if (!FQDN_REGEX.test(relayDomain)) { + throw new Error('RELAY_DOMAIN must be a valid fully-qualified domain name when using --nginx.') + } + + const certbotEmail = process.env.CERTBOT_EMAIL?.trim() + if (!certbotEmail) { + throw new Error( + 'CERTBOT_EMAIL environment variable is required when using --nginx (example: CERTBOT_EMAIL=you@example.com).', + ) + } + + const nginxConfDir = getProjectPath('nginx', 'conf.d') + const nginxTemplate = join(nginxConfDir, 'nostream.conf.template') + const nginxConf = join(nginxConfDir, 'nostream.conf') + + const templateContent = fs.readFileSync(nginxTemplate, 'utf-8') + const rendered = templateContent.replaceAll('${RELAY_DOMAIN}', relayDomain) + fs.writeFileSync(nginxConf, rendered, { encoding: 'utf-8' }) + + const sslCertDir = getProjectPath('nginx', 'ssl', 'live', relayDomain) + const fullchainPath = join(sslCertDir, 'fullchain.pem') + const privkeyPath = join(sslCertDir, 'privkey.pem') + + if (!fs.existsSync(fullchainPath) || !fs.existsSync(privkeyPath)) { + fs.mkdirSync(sslCertDir, { recursive: true }) + + const code = await runCommand('openssl', [ + 'req', + '-x509', + '-nodes', + '-newkey', + 'rsa:2048', + '-days', + '1', + '-keyout', + privkeyPath, + '-out', + fullchainPath, + '-subj', + `/CN=${relayDomain}`, + ]) + + if (code !== 0) { + throw new Error('Failed to generate self-signed SSL certificate. Ensure openssl is installed and retry.') + } + } +} + +export const runStart = async (options: StartOptions, passthrough: string[]): Promise => { + ensureNotRoot() + + const explicitPortFlag = process.argv.slice(2).some((arg) => arg === '--port' || arg.startsWith('--port=')) + const hasPort = typeof options.port === 'number' && Number.isFinite(options.port) + if (explicitPortFlag && !hasPort) { + throw new Error('Port must be a safe integer between 1 and 65535') + } + + if (hasPort) { + if (!Number.isSafeInteger(options.port) || options.port < 1 || options.port > 65535) { + throw new Error('Port must be a safe integer between 1 and 65535') + } + } + + logStep('Preparing configuration') + ensureConfigBootstrap() + + const composeFiles = ['docker-compose.yml'] + + if (options.tor) { + ensureTorDataDir() + composeFiles.push('docker-compose.tor.yml') + } + + if (options.i2p) { + ensureI2PDataDir() + composeFiles.push('docker-compose.i2p.yml') + } + + if (options.nginx) { + await ensureNginxBootstrap() + composeFiles.push('docker-compose.nginx.yml') + } + + let overrideFile: string | undefined + if (hasPort) { + overrideFile = createPortOverrideComposeFile(options.port) + composeFiles.push(overrideFile) + } + + const env: NodeJS.ProcessEnv = {} + + if (options.debug) { + env.DEBUG = process.env.DEBUG || 'primary:*,worker:*,knex:*' + } + + const spinner = ora('Starting relay...').start() + const composePassthrough = passthrough.filter((arg) => arg !== '--') + const upArgs = ['up', '--build', '--remove-orphans', ...(options.detach ? ['-d'] : []), ...composePassthrough] + + try { + const code = await runDockerCompose({ + files: composeFiles, + args: upArgs, + env, + }) + + if (code === 0) { + spinner.succeed('Relay start command completed') + } else { + spinner.fail('Relay start command failed') + } + + return code + } finally { + if (overrideFile && fs.existsSync(overrideFile)) { + fs.unlinkSync(overrideFile) + } + } +} diff --git a/src/cli/commands/stop.ts b/src/cli/commands/stop.ts new file mode 100644 index 00000000..73c0179b --- /dev/null +++ b/src/cli/commands/stop.ts @@ -0,0 +1,40 @@ +import ora from 'ora' + +import { StopOptions } from '../types' +import { runDockerCompose } from '../utils/docker' + +export const runStop = async (options: StopOptions, passthrough: string[]): Promise => { + const composeFiles = ['docker-compose.yml'] + + const includeAll = options.all || (!options.tor && !options.i2p && !options.nginx && !options.local) + + if (includeAll || options.tor) { + composeFiles.push('docker-compose.tor.yml') + } + + if (includeAll || options.i2p) { + composeFiles.push('docker-compose.i2p.yml') + } + + if (includeAll || options.nginx) { + composeFiles.push('docker-compose.nginx.yml') + } + + if (includeAll || options.local) { + composeFiles.push('docker-compose.local.yml') + } + + const spinner = ora('Stopping relay...').start() + const code = await runDockerCompose({ + files: composeFiles, + args: ['down', ...passthrough], + }) + + if (code === 0) { + spinner.succeed('Relay stop command completed') + } else { + spinner.fail('Relay stop command failed') + } + + return code +} diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts new file mode 100644 index 00000000..6fb3026d --- /dev/null +++ b/src/cli/commands/update.ts @@ -0,0 +1,64 @@ +import ora from 'ora' + +import { runCommand, runCommandWithOutput } from '../utils/process' +import { runStart } from './start' +import { runStop } from './stop' + +const wasStashCreated = (output: string): boolean => { + return !output.includes('No local changes to save') +} + +export const runUpdate = async (passthrough: string[]): Promise => { + const spinner = ora('Updating relay...').start() + + let code = await runStop({ all: true }, []) + if (code !== 0) { + spinner.fail('Update failed while stopping relay') + return code + } + + const stashResult = await runCommandWithOutput('git', ['stash', 'push', '-u', '-m', 'nostream-cli-update']) + if (stashResult.code !== 0) { + spinner.fail('Update failed while stashing local changes') + return stashResult.code + } + + const stashOutput = `${stashResult.stdout}\n${stashResult.stderr}` + const stashCreated = wasStashCreated(stashOutput) + + code = await runCommand('git', ['pull']) + if (code !== 0) { + if (stashCreated) { + const restoreCode = await runCommand('git', ['stash', 'pop']) + if (restoreCode === 0) { + spinner.fail('Update failed while pulling latest changes. Restored stashed local changes.') + return code + } + + spinner.fail( + 'Update failed while pulling latest changes, and restoring stashed local changes also failed. Run `git stash list` then `git stash pop` manually.', + ) + return restoreCode + } + + spinner.fail('Update failed while pulling latest changes.') + return code + } + + if (stashCreated) { + code = await runCommand('git', ['stash', 'pop']) + if (code !== 0) { + spinner.fail('Update pulled latest changes, but reapplying stashed changes failed') + return code + } + } + + code = await runStart({}, passthrough) + if (code !== 0) { + spinner.fail('Update finished pull, but restart failed') + return code + } + + spinner.succeed('Relay updated and restarted') + return 0 +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 00000000..62a570d7 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,465 @@ +#!/usr/bin/env node +const { cac } = require('cac') + +import { runStart } from './commands/start' +import { runStop } from './commands/stop' +import { runInfo } from './commands/info' +import { runImport } from './commands/import' +import { runExport } from './commands/export' +import { runSetup } from './commands/setup' +import { runSeed } from './commands/seed' +import { runUpdate } from './commands/update' +import { + runConfigEnvGet, + runConfigEnvList, + runConfigEnvSet, + runConfigEnvValidate, + runConfigGet, + runConfigList, + runConfigSet, + runConfigValidate, +} from './commands/config' +import { + runDevDbClean, + runDevDbReset, + runDevDockerClean, + runDevSeedRelay, + runDevTestCli, + runDevTestIntegration, + runDevTestUnit, +} from './commands/dev' +import { runTui } from './tui/main' +import { logError, logInfo } from './utils/output' + +class CliUsageError extends Error {} + +const printHandledError = (message: string): void => { + logError(`Error: ${message}`) +} + +const isStructuredExportFormat = (value: string): value is 'json' | 'jsonl' => { + return value === 'json' || value === 'jsonl' +} + +const isCompressionExportFormat = (value: string): value is 'gzip' | 'gz' | 'xz' => { + return value === 'gzip' || value === 'gz' || value === 'xz' +} + +const extractFormatValues = (args: string[]): string[] => { + const formats: string[] = [] + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + + if (arg === '--format') { + const value = args[index + 1] + if (typeof value === 'string') { + formats.push(value) + } + index += 1 + continue + } + + if (arg.startsWith('--format=')) { + const value = arg.slice('--format='.length) + if (value.length) { + formats.push(value) + } + } + } + + return formats +} + +const cli = cac('nostream') + +const configSubHelp: Record = { + list: 'Usage: nostream config list', + get: 'Usage: nostream config get ', + set: 'Usage: nostream config set [--type inferred|json] [--validate|--no-validate] [--restart]', + validate: 'Usage: nostream config validate', + env: 'Usage: nostream config env [args] [--show-secrets]', +} + +const configEnvSubHelp: Record = { + list: 'Usage: nostream config env list [--show-secrets]', + get: 'Usage: nostream config env get [--show-secrets]', + set: 'Usage: nostream config env set ', + validate: 'Usage: nostream config env validate', +} + +const devSubHelp: Record = { + 'db:clean': 'Usage: nostream dev db:clean [--all|--older-than=|--kinds=1,7,4] [--dry-run] [--force]', + 'db:reset': 'Usage: nostream dev db:reset [--yes]', + 'seed:relay': 'Usage: nostream dev seed:relay', + 'docker:clean': 'Usage: nostream dev docker:clean [--yes]', + 'test:unit': 'Usage: nostream dev test:unit', + 'test:cli': 'Usage: nostream dev test:cli', + 'test:integration': 'Usage: nostream dev test:integration', +} + +const withErrorBoundary = + (handler: (...args: T) => Promise | number) => + async (...args: T) => { + try { + const code = await handler(...args) + if (typeof code === 'number' && code !== 0) { + process.exitCode = code + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const usageError = error instanceof CliUsageError + printHandledError(message) + process.exitCode = usageError ? 2 : 1 + } + } + +cli + .command('start [...args]', 'Start Nostream (Docker Compose)') + .option('--tor', 'Enable Tor compose overlay') + .option('--i2p', 'Enable I2P compose overlay') + .option('--nginx', 'Enable Nginx reverse proxy overlay') + .option('--debug', 'Enable DEBUG logging') + .option('-d, --detach', 'Start in detached mode') + .option('--port ', 'Override exposed relay port', { type: [Number] }) + .action( + withErrorBoundary(async (args: unknown, options: unknown) => { + const resolved = options as Record + const normalizedPort = Array.isArray(resolved.port) ? resolved.port[0] : resolved.port + return runStart({ ...(resolved as any), port: normalizedPort as number | undefined }, args as string[]) + }), + ) + +cli + .command('stop [...args]', 'Stop Nostream') + .option('--tor', 'Include Tor overlay') + .option('--i2p', 'Include I2P overlay') + .option('--nginx', 'Include Nginx overlay') + .option('--local', 'Include local dev overlay') + .option('--all', 'Include all known overlays') + .action( + withErrorBoundary(async (args: unknown, options: unknown) => { + return runStop(options as any, args as string[]) + }), + ) + +cli + .command('info', 'Show relay/runtime info') + .option('--tor-hostname', 'Print Tor hostname only') + .option('--i2p-hostname', 'Print I2P hostname(s) when available') + .action( + withErrorBoundary(async (options: unknown) => { + return runInfo(options as any) + }), + ) + +cli + .command('update [...args]', 'Pull latest git changes and restart relay') + .action( + withErrorBoundary(async (args: unknown) => { + return runUpdate(args as string[]) + }), + ) + +cli + .command('clean', 'Clean Docker resources (legacy script replacement)') + .action( + withErrorBoundary(async () => { + return runDevDockerClean({ yes: true }) + }), + ) + +cli + .command('import [file] [...args]', 'Import events from .jsonl or .json') + .option('--file ', 'Path to .jsonl/.json file (alias to positional arg)') + .option('--batch-size ', 'Batch size', { type: [Number] }) + .action( + withErrorBoundary(async (file: unknown, args: unknown, options: unknown) => { + const passthrough = (args as string[]) ?? [] + const unsupportedFlag = passthrough.find((arg) => arg.startsWith('-')) + if (unsupportedFlag) { + throw new CliUsageError(`Unknown option: ${unsupportedFlag}`) + } + + const resolved = options as Record + const rawBatchSize = Array.isArray(resolved.batchSize) ? resolved.batchSize[0] : resolved.batchSize + const normalizedBatchSize = + typeof rawBatchSize === 'number' && Number.isFinite(rawBatchSize) ? rawBatchSize : undefined + const normalizedFile = (resolved.file as string | undefined) ?? (file as string | undefined) + if (normalizedFile && normalizedFile.startsWith('-')) { + throw new CliUsageError(`Unknown option: ${normalizedFile}`) + } + + return runImport( + { + ...(resolved as any), + file: normalizedFile, + batchSize: normalizedBatchSize as number | undefined, + }, + passthrough, + ) + }), + ) + +cli + .command('export [output] [...args]', 'Export events to file') + .option('--output ', 'Output path (alias to positional arg)') + .option('-z, --compress', 'Enable compression (legacy compatibility)') + .option('--format ', 'Export format (jsonl|json|gzip|gz|xz)') + .action( + withErrorBoundary(async (output: unknown, args: unknown, options: unknown) => { + const passthrough = (args as string[]) ?? [] + const resolved = options as Record + + const formatCandidates = new Set(extractFormatValues(passthrough)) + if (typeof resolved.format === 'string' && resolved.format.length > 0) { + formatCandidates.add(resolved.format) + } + + const unknownFormats = [...formatCandidates].filter( + (format) => !isStructuredExportFormat(format) && !isCompressionExportFormat(format), + ) + if (unknownFormats.length > 0) { + throw new CliUsageError( + `Unsupported format: ${unknownFormats[0]}. Supported values: json, jsonl, gzip, gz, xz`, + ) + } + + const structuredFormats = [...formatCandidates].filter(isStructuredExportFormat) + const compressionFormats = [...formatCandidates].filter(isCompressionExportFormat) + + if (structuredFormats.length > 1) { + throw new CliUsageError('Conflicting structured export formats were provided. Use only one of: json, jsonl') + } + + const compressionFamilies = new Set(compressionFormats.map((format) => (format === 'xz' ? 'xz' : 'gzip'))) + if (compressionFamilies.size > 1) { + throw new CliUsageError( + 'Conflicting compression formats were provided. Use only one of: gzip/gz or xz', + ) + } + + if (structuredFormats.length > 0 && compressionFormats.length > 0) { + throw new CliUsageError('Cannot combine structured export format (json/jsonl) with compression format (gzip/gz/xz).') + } + + const compress = + Boolean(resolved.compress) || passthrough.includes('--compress') || passthrough.includes('-z') + if (structuredFormats.length > 0 && compress) { + throw new CliUsageError('Cannot combine --compress with --format json/jsonl.') + } + + return runExport( + { + ...(resolved as any), + output: (resolved.output as string | undefined) ?? (output as string | undefined), + format: structuredFormats[0] as 'json' | 'jsonl' | undefined, + compress, + compressionFormat: compressionFormats[0] as 'gzip' | 'gz' | 'xz' | undefined, + }, + passthrough, + ) + }), + ) + +cli + .command('seed', 'Seed relay with test data') + .option('--count ', 'Number of events to seed', { type: [Number] }) + .action( + withErrorBoundary(async (options: unknown) => { + const resolved = options as Record + const normalizedCount = Array.isArray(resolved.count) ? resolved.count[0] : resolved.count + return runSeed({ ...(resolved as any), count: normalizedCount as number | undefined }) + }), + ) + +cli + .command('setup', 'Initial interactive setup') + .option('-y, --yes', 'Non-interactive defaults') + .option('--start', 'Start relay after setup') + .action(withErrorBoundary(async (options: unknown) => runSetup(options as any))) + +cli + .command('config [...args]', 'Manage settings') + .option('--restart', 'Restart relay after setting update') + .option('--validate', 'Validate merged settings before write') + .option('--no-validate', 'Skip validation before write') + .option('--type ', 'Value parser: inferred|json') + .option('--show-secrets', 'Show secret values for env commands') + .action( + withErrorBoundary(async (args: unknown, options: unknown) => { + const positional = (args as string[]) ?? [] + const command = positional[0] + const resolved = options as Record + + if (resolved.help && command === 'env') { + const envCommand = positional[1] + if (envCommand && configEnvSubHelp[envCommand]) { + logInfo(configEnvSubHelp[envCommand]) + return 0 + } + + logInfo(configSubHelp.env) + return 0 + } + + if (resolved.help && command && configSubHelp[command]) { + logInfo(configSubHelp[command]) + return 0 + } + + if (command === 'env') { + const envCommand = positional[1] + const showSecrets = Boolean(resolved.showSecrets) + + switch (envCommand) { + case 'list': + return runConfigEnvList({ showSecrets }) + case 'get': + if (!positional[2]) { + throw new CliUsageError(configEnvSubHelp.get) + } + return runConfigEnvGet(positional[2], { showSecrets }) + case 'set': + if (!positional[2] || positional[3] === undefined) { + throw new CliUsageError(configEnvSubHelp.set) + } + return runConfigEnvSet(positional[2], positional[3]) + case 'validate': + return runConfigEnvValidate() + default: + logInfo(configSubHelp.env) + return 2 + } + } + + switch (command) { + case 'list': + return runConfigList() + case 'get': + if (!positional[1]) { + throw new CliUsageError(configSubHelp.get) + } + return runConfigGet(positional[1]) + case 'set': { + if (!positional[1] || positional[2] === undefined) { + throw new CliUsageError(configSubHelp.set) + } + + const valueType = ((resolved.type as string | undefined) ?? 'inferred') as 'inferred' | 'json' + if (valueType !== 'inferred' && valueType !== 'json') { + throw new CliUsageError(`Unsupported type: ${valueType}. Supported values: inferred, json`) + } + + return runConfigSet(positional[1], positional[2], { + restart: Boolean(resolved.restart), + validate: resolved.validate !== false, + valueType, + }) + } + case 'validate': + return runConfigValidate() + default: + logInfo('Usage: nostream config [args]') + return 2 + } + }), + ) + +cli + .command('dev [...args]', 'Development utilities') + .option('-y, --yes', 'Skip confirmation') + .action( + withErrorBoundary(async (args: unknown, options: unknown) => { + const positional = (args as string[]) ?? [] + const command = positional[0] + const resolved = options as Record + + if (resolved.help && command && devSubHelp[command]) { + logInfo(devSubHelp[command]) + return 0 + } + + switch (command) { + case 'db:clean': + return runDevDbClean(positional.slice(1), resolved as any) + case 'db:reset': + return runDevDbReset(resolved as any) + case 'seed:relay': + return runDevSeedRelay() + case 'docker:clean': + return runDevDockerClean(resolved as any) + case 'test:unit': + return runDevTestUnit() + case 'test:cli': + return runDevTestCli() + case 'test:integration': + return runDevTestIntegration() + default: + logInfo( + 'Usage: nostream dev [args]', + ) + return 2 + } + }), + ) + +cli.help() +cli.version('2.1.1') + +withErrorBoundary(async () => { + const userArgs = process.argv.slice(2) + const knownTopLevel = new Set([ + 'start', + 'stop', + 'info', + 'import', + 'export', + 'seed', + 'setup', + 'config', + 'dev', + 'update', + 'clean', + ]) + + if (userArgs.length >= 2 && userArgs.includes('--help')) { + if (userArgs[0] === 'config' && userArgs[1] === 'env') { + if (userArgs[2] && configEnvSubHelp[userArgs[2]]) { + logInfo(configEnvSubHelp[userArgs[2]]) + return 0 + } + + logInfo(configSubHelp.env) + return 0 + } + + if (userArgs[0] === 'config' && configSubHelp[userArgs[1]]) { + logInfo(configSubHelp[userArgs[1]]) + return 0 + } + + if (userArgs[0] === 'dev' && devSubHelp[userArgs[1]]) { + logInfo(devSubHelp[userArgs[1]]) + return 0 + } + } + + if (userArgs.length > 0 && !userArgs[0].startsWith('-') && !knownTopLevel.has(userArgs[0])) { + logError(`Unknown command: ${userArgs[0]}`) + cli.outputHelp() + return 2 + } + + if (userArgs.length === 0) { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + cli.outputHelp() + return 0 + } + + return runTui() + } + + await cli.parse(process.argv) + return typeof process.exitCode === 'number' ? process.exitCode : 0 +})() diff --git a/src/cli/tui/main.ts b/src/cli/tui/main.ts new file mode 100644 index 00000000..990ec276 --- /dev/null +++ b/src/cli/tui/main.ts @@ -0,0 +1,59 @@ +import { runInfo } from '../commands/info' +import { runStartMenu } from './menus/start' +import { runConfigureMenu } from './menus/configure' +import { runManageMenu } from './menus/manage' +import { runDevMenu } from './menus/dev' +import { runStop } from '../commands/stop' +import { createState } from './state' +import { tuiPrompts } from './prompts' + +export const runTui = async (): Promise => { + const state = createState() + tuiPrompts.intro('Nostream Control Center') + + while (state.running) { + const action = await tuiPrompts.select({ + message: 'What would you like to do?', + options: [ + { value: 'start', label: 'Start relay' }, + { value: 'stop', label: 'Stop relay' }, + { value: 'configure', label: 'Configure settings' }, + { value: 'manage', label: 'Manage data (export/import)' }, + { value: 'dev', label: 'Development tools' }, + { value: 'info', label: 'View relay info' }, + { value: 'exit', label: 'Exit' }, + ], + }) + + if (tuiPrompts.isCancel(action) || action === 'exit') { + state.running = false + break + } + + switch (action) { + case 'start': + await runStartMenu() + break + case 'stop': + await runStop({ all: true }, []) + break + case 'configure': + await runConfigureMenu() + break + case 'manage': + await runManageMenu() + break + case 'dev': + await runDevMenu() + break + case 'info': + await runInfo({}) + break + default: + tuiPrompts.cancel('Unknown action') + } + } + + tuiPrompts.outro('Goodbye') + return 0 +} diff --git a/src/cli/tui/menus/configure.ts b/src/cli/tui/menus/configure.ts new file mode 100644 index 00000000..8d51d584 --- /dev/null +++ b/src/cli/tui/menus/configure.ts @@ -0,0 +1,112 @@ +import { + getConfigTopLevelCategories, + runConfigGet, + runConfigList, + runConfigSet, + runConfigValidate, +} from '../../commands/config' +import { tuiPrompts } from '../prompts' + +const toCategoryLabel = (key: string): string => { + return key + .split(/[_\-.]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +const getCategoryOptions = () => { + const categories = getConfigTopLevelCategories().sort((a, b) => a.localeCompare(b)) + + return [ + ...categories.map((category) => ({ + value: category, + label: toCategoryLabel(category), + })), + { value: 'other', label: 'Other / full path' }, + ] +} + +export const runConfigureMenu = async (): Promise => { + const action = await tuiPrompts.select({ + message: 'Configuration action', + options: [ + { value: 'list', label: 'List all settings' }, + { value: 'get', label: 'Get setting by dot-path' }, + { value: 'set', label: 'Set setting by dot-path' }, + { value: 'validate', label: 'Validate settings' }, + { value: 'back', label: 'Back' }, + ], + }) + + if (tuiPrompts.isCancel(action)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + if (action === 'back') { + return 0 + } + + if (action === 'list') { + return runConfigList() + } + + if (action === 'validate') { + return runConfigValidate() + } + + const category = await tuiPrompts.select({ + message: 'Configuration category', + options: [...getCategoryOptions(), { value: 'back', label: 'Back' }], + }) + if (tuiPrompts.isCancel(category)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + if (category === 'back') { + return 0 + } + + const pathInput = await tuiPrompts.text({ + message: category === 'other' ? 'Full dot-path' : `Path inside ${category} (without "${category}.")`, + placeholder: category === 'other' ? 'payments.enabled' : 'enabled', + validate: (input) => (input.trim() ? undefined : 'Path is required'), + }) + if (tuiPrompts.isCancel(pathInput)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + const normalizedPath = pathInput.trim() + const path = category === 'other' ? normalizedPath : `${category}.${normalizedPath}` + + const confirmedPath = await tuiPrompts.confirm({ + message: `Use path: ${path}?`, + initialValue: true, + }) + if (tuiPrompts.isCancel(confirmedPath) || !confirmedPath) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + if (action === 'get') { + return runConfigGet(path) + } + + const value = await tuiPrompts.text({ message: 'New value (true/false/number/string/json)' }) + if (tuiPrompts.isCancel(value)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + const restart = await tuiPrompts.confirm({ + message: 'Restart relay after this setting change?', + initialValue: false, + }) + if (tuiPrompts.isCancel(restart)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + return runConfigSet(path, value, { restart, validate: true, valueType: 'inferred' }) +} diff --git a/src/cli/tui/menus/dev.ts b/src/cli/tui/menus/dev.ts new file mode 100644 index 00000000..d576561a --- /dev/null +++ b/src/cli/tui/menus/dev.ts @@ -0,0 +1,98 @@ +import { runDevDbClean, runDevDbReset, runDevDockerClean, runDevSeedRelay } from '../../commands/dev' +import { tuiPrompts } from '../prompts' +import ora from 'ora' + +const confirmDanger = async (message: string): Promise => { + const answer = await tuiPrompts.confirm({ + message: `Destructive action: ${message}`, + initialValue: false, + }) + + if (tuiPrompts.isCancel(answer) || !answer) { + tuiPrompts.cancel('Cancelled') + return false + } + + return true +} + +export const runDevMenu = async (): Promise => { + const action = await tuiPrompts.select({ + message: 'Development utilities', + options: [ + { value: 'db:clean', label: 'Clean DB (events)' }, + { value: 'db:reset', label: 'Reset DB (rollback+migrate)' }, + { value: 'seed:relay', label: 'Seed relay data' }, + { value: 'docker:clean', label: 'Docker system/volume clean' }, + { value: 'back', label: 'Back' }, + ], + }) + + if (tuiPrompts.isCancel(action)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + if (action === 'back') { + return 0 + } + + switch (action) { + case 'db:clean': { + const confirmed = await confirmDanger('delete events from the database') + if (!confirmed) { + return 1 + } + + const spinner = ora('Cleaning database...').start() + const code = await runDevDbClean(['--all', '--force']) + if (code === 0) { + spinner.succeed('Database clean completed') + } else { + spinner.fail('Database clean failed') + } + return code + } + case 'db:reset': { + const confirmed = await confirmDanger('reset database and rerun migrations') + if (!confirmed) { + return 1 + } + + const spinner = ora('Resetting database...').start() + const code = await runDevDbReset({ yes: true }) + if (code === 0) { + spinner.succeed('Database reset completed') + } else { + spinner.fail('Database reset failed') + } + return code + } + case 'seed:relay': { + const spinner = ora('Seeding relay...').start() + const code = await runDevSeedRelay() + if (code === 0) { + spinner.succeed('Relay seed completed') + } else { + spinner.fail('Relay seed failed') + } + return code + } + case 'docker:clean': { + const confirmed = await confirmDanger('remove unused Docker images and volumes') + if (!confirmed) { + return 1 + } + + const spinner = ora('Cleaning Docker resources...').start() + const code = await runDevDockerClean({ yes: true }) + if (code === 0) { + spinner.succeed('Docker clean completed') + } else { + spinner.fail('Docker clean failed') + } + return code + } + default: + return 1 + } +} diff --git a/src/cli/tui/menus/manage.ts b/src/cli/tui/menus/manage.ts new file mode 100644 index 00000000..9995aa78 --- /dev/null +++ b/src/cli/tui/menus/manage.ts @@ -0,0 +1,124 @@ +import { runExport } from '../../commands/export' +import { runImport } from '../../commands/import' +import { logError } from '../../utils/output' +import { tuiPrompts } from '../prompts' +import ora from 'ora' + +export const runManageMenu = async (): Promise => { + const action = await tuiPrompts.select({ + message: 'Data management', + options: [ + { value: 'export', label: 'Export events' }, + { value: 'import', label: 'Import events' }, + { value: 'back', label: 'Back' }, + ], + }) + + if (tuiPrompts.isCancel(action)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + if (action === 'back') { + return 0 + } + + if (action === 'export') { + const format = await tuiPrompts.select({ + message: 'Output format', + options: [ + { value: 'jsonl', label: 'JSON Lines (.jsonl)' }, + { value: 'json', label: 'JSON array (.json)' }, + { value: 'back', label: 'Back' }, + ], + }) + if (tuiPrompts.isCancel(format)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + if (format === 'back') { + return 0 + } + + const output = await tuiPrompts.text({ + message: 'Output filename', + defaultValue: format === 'json' ? 'events.json' : 'events.jsonl', + }) + if (tuiPrompts.isCancel(output)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + const confirmed = await tuiPrompts.confirm({ + message: `Export events to ${output}?`, + initialValue: true, + }) + if (tuiPrompts.isCancel(confirmed) || !confirmed) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + const spinner = ora('Exporting events...').start() + const code = await runExport({ output, format: format as 'json' | 'jsonl' }, []) + if (code === 0) { + spinner.succeed('Export completed') + } else { + spinner.fail('Export failed') + } + return code + } + + const format = await tuiPrompts.select({ + message: 'Input format', + options: [ + { value: 'jsonl', label: 'JSON Lines (.jsonl)' }, + { value: 'json', label: 'JSON array (.json)' }, + { value: 'back', label: 'Back' }, + ], + }) + if (tuiPrompts.isCancel(format)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + if (format === 'back') { + return 0 + } + + const file = await tuiPrompts.text({ + message: 'Input file path', + defaultValue: format === 'json' ? 'events.json' : 'events.jsonl', + }) + if (tuiPrompts.isCancel(file)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + const batchSizeRaw = await tuiPrompts.text({ message: 'Batch size', defaultValue: '1000' }) + if (tuiPrompts.isCancel(batchSizeRaw)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + const batchSize = Number(batchSizeRaw) + if (!Number.isSafeInteger(batchSize) || batchSize <= 0) { + logError('Batch size must be a positive integer') + return 1 + } + + const confirmed = await tuiPrompts.confirm({ + message: `Import events from ${file}?`, + initialValue: true, + }) + if (tuiPrompts.isCancel(confirmed) || !confirmed) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + const spinner = ora('Importing events...').start() + const code = await runImport({ file, batchSize }, []) + if (code === 0) { + spinner.succeed('Import completed') + } else { + spinner.fail('Import failed') + } + return code +} diff --git a/src/cli/tui/menus/start.ts b/src/cli/tui/menus/start.ts new file mode 100644 index 00000000..29027db3 --- /dev/null +++ b/src/cli/tui/menus/start.ts @@ -0,0 +1,84 @@ +import { runStart } from '../../commands/start' +import { tuiPrompts } from '../prompts' +import ora from 'ora' + +export const runStartMenu = async (): Promise => { + const action = await tuiPrompts.select({ + message: 'Start relay', + options: [ + { value: 'continue', label: 'Continue' }, + { value: 'back', label: 'Back' }, + ], + }) + if (tuiPrompts.isCancel(action)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + if (action === 'back') { + return 0 + } + + const tor = await tuiPrompts.confirm({ message: 'Enable Tor?', initialValue: false }) + if (tuiPrompts.isCancel(tor)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + const i2p = await tuiPrompts.confirm({ message: 'Enable I2P?', initialValue: false }) + if (tuiPrompts.isCancel(i2p)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + const debug = await tuiPrompts.confirm({ message: 'Enable debug logs?', initialValue: false }) + if (tuiPrompts.isCancel(debug)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + const useCustomPort = await tuiPrompts.confirm({ message: 'Override relay port?', initialValue: false }) + if (tuiPrompts.isCancel(useCustomPort)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + let port: number | undefined + if (useCustomPort) { + const portInput = await tuiPrompts.text({ + message: 'Relay port (1-65535)', + defaultValue: '8008', + validate: (input) => { + const parsed = Number(input) + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) { + return 'Port must be a safe integer between 1 and 65535' + } + return undefined + }, + }) + if (tuiPrompts.isCancel(portInput)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + port = Number(portInput) + } + + const confirmed = await tuiPrompts.confirm({ + message: `Start relay${tor ? ' with Tor' : ''}${i2p ? `${tor ? ' + ' : ' with '}I2P` : ''}${debug ? ' (debug)' : ''}${port ? ` on port ${port}` : ''}?`, + initialValue: true, + }) + if (tuiPrompts.isCancel(confirmed) || !confirmed) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + const spinner = ora('Starting relay...').start() + const code = await runStart({ tor, i2p, debug, port }, []) + if (code === 0) { + spinner.succeed('Relay start command completed') + } else { + spinner.fail('Relay start command failed') + } + + return code +} diff --git a/src/cli/tui/menus/stop.ts b/src/cli/tui/menus/stop.ts new file mode 100644 index 00000000..47f2c455 --- /dev/null +++ b/src/cli/tui/menus/stop.ts @@ -0,0 +1,27 @@ +import ora from 'ora' + +import { runStop } from '../../commands/stop' +import { tuiPrompts } from '../prompts' + +export const runStopMenu = async (): Promise => { + const nginx = await tuiPrompts.confirm({ + message: 'Include Nginx stack while stopping?', + initialValue: false, + }) + + if (tuiPrompts.isCancel(nginx)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + const spinner = ora('Stopping relay...').start() + const code = await runStop({ tor: true, i2p: true, local: true, nginx }, []) + + if (code === 0) { + spinner.succeed('Relay stop command completed') + } else { + spinner.fail('Relay stop command failed') + } + + return code +} diff --git a/src/cli/tui/prompts.ts b/src/cli/tui/prompts.ts new file mode 100644 index 00000000..b49dde13 --- /dev/null +++ b/src/cli/tui/prompts.ts @@ -0,0 +1,11 @@ +import { cancel, confirm, intro, isCancel, outro, select, text } from '@clack/prompts' + +export const tuiPrompts = { + cancel, + confirm, + intro, + isCancel, + outro, + select, + text, +} diff --git a/src/cli/tui/state.ts b/src/cli/tui/state.ts new file mode 100644 index 00000000..87bec975 --- /dev/null +++ b/src/cli/tui/state.ts @@ -0,0 +1,7 @@ +export type TuiState = { + running: boolean +} + +export const createState = (): TuiState => ({ + running: true, +}) diff --git a/src/cli/types.ts b/src/cli/types.ts new file mode 100644 index 00000000..d84b7624 --- /dev/null +++ b/src/cli/types.ts @@ -0,0 +1,27 @@ +export type CommandContext = { + cwd: string +} + +export type CommandHandler> = (options: T, rawArgs: string[]) => Promise + +export type StartOptions = { + tor?: boolean + i2p?: boolean + nginx?: boolean + debug?: boolean + port?: number + detach?: boolean +} + +export type StopOptions = { + tor?: boolean + i2p?: boolean + nginx?: boolean + local?: boolean + all?: boolean +} + +export type SetupOptions = { + yes?: boolean + start?: boolean +} diff --git a/src/cli/utils/bootstrap.ts b/src/cli/utils/bootstrap.ts new file mode 100644 index 00000000..3fce9904 --- /dev/null +++ b/src/cli/utils/bootstrap.ts @@ -0,0 +1,38 @@ +import fs from 'fs' +import { homedir } from 'os' +import { join } from 'path' + +import { getConfigBaseDir, getDefaultSettingsFilePath, getProjectPath, getSettingsFilePath } from './paths' + +export const ensureNotRoot = (): void => { + if (typeof process.geteuid === 'function' && process.geteuid() === 0) { + throw new Error('Nostream should not be run as root.') + } +} + +export const ensureConfigBootstrap = (): void => { + const configDir = getConfigBaseDir() + const settingsFile = getSettingsFilePath() + const defaultsFile = getDefaultSettingsFilePath() + + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }) + } + + if (!fs.existsSync(settingsFile)) { + fs.copyFileSync(defaultsFile, settingsFile) + } +} + +export const ensureTorDataDir = (): void => { + fs.mkdirSync(getProjectPath('.nostr', 'tor', 'data'), { recursive: true }) +} + +export const ensureI2PDataDir = (): void => { + fs.mkdirSync(getProjectPath('.nostr', 'i2p', 'data'), { recursive: true }) +} + +export const getTorHostnamePath = (): string => getProjectPath('.nostr', 'tor', 'data', 'nostream', 'hostname') + +export const getOnionKeyPath = (): string => + join(process.env.NOSTR_CONFIG_DIR ?? join(homedir(), '.nostr'), 'v3_onion_private_key') diff --git a/src/cli/utils/config.ts b/src/cli/utils/config.ts new file mode 100644 index 00000000..33cb768d --- /dev/null +++ b/src/cli/utils/config.ts @@ -0,0 +1,420 @@ +import fs from 'fs' +import yaml from 'js-yaml' +import { mergeDeepRight } from 'ramda' + +import { Settings } from '../../@types/settings' +import { getConfigBaseDir, getDefaultSettingsFilePath, getSettingsFilePath } from './paths' + +export type ValidationIssue = { + path: string + message: string +} + +type PathToken = + | { + type: 'key' + key: string + } + | { + type: 'index' + index: number + } + +const isPlainObject = (value: unknown): value is Record => { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +const parsePath = (path: string): PathToken[] => { + const input = path.trim() + + if (!input) { + throw new Error('Path is required') + } + + const tokens: PathToken[] = [] + const segments = input.split('.').map((part) => part.trim()) + + for (const segment of segments) { + if (!segment) { + throw new Error(`Invalid path segment in: ${path}`) + } + + const match = segment.match(/^([A-Za-z_][A-Za-z0-9_]*)(\[(\d+)\])*$/) + if (!match) { + throw new Error(`Invalid path segment: ${segment}`) + } + + tokens.push({ type: 'key', key: match[1] }) + + const indexes = [...segment.matchAll(/\[(\d+)\]/g)] + for (const entry of indexes) { + tokens.push({ + type: 'index', + index: Number(entry[1]), + }) + } + } + + return tokens +} + +const formatPathTokens = (tokens: PathToken[]): string => { + let out = '' + + for (const token of tokens) { + if (token.type === 'key') { + out = out ? `${out}.${token.key}` : token.key + continue + } + + out = `${out}[${token.index}]` + } + + return out +} + +export const parseValue = (raw: string): unknown => { + const trimmed = raw.trim() + + if (trimmed === 'true') { + return true + } + + if (trimmed === 'false') { + return false + } + + if (trimmed === 'null') { + return null + } + + if (/^-?\d+$/.test(trimmed)) { + const asNumber = Number(trimmed) + if (Number.isSafeInteger(asNumber)) { + return asNumber + } + } + + if (/^-?\d+n$/.test(trimmed)) { + return BigInt(trimmed.slice(0, -1)) + } + + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + return JSON.parse(trimmed) + } catch { + return raw + } + } + + return raw +} + +export const parseTypedValue = (raw: string, type: 'inferred' | 'json' = 'inferred'): unknown => { + if (type === 'json') { + try { + return JSON.parse(raw) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Invalid JSON value: ${message}`) + } + } + + return parseValue(raw) +} + +const toSerializable = (value: unknown): unknown => { + if (typeof value === 'bigint') { + return value.toString() + } + + if (Array.isArray(value)) { + return value.map((entry) => toSerializable(entry)) + } + + if (isPlainObject(value)) { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, toSerializable(entry)])) + } + + return value +} + +const validateShape = (schema: unknown, candidate: unknown, path: PathToken[], issues: ValidationIssue[]): void => { + if (schema === undefined || candidate === undefined) { + return + } + + const renderedPath = formatPathTokens(path) || '$' + + if (Array.isArray(schema)) { + if (!Array.isArray(candidate)) { + issues.push({ + path: renderedPath, + message: `Expected array, got ${typeof candidate}`, + }) + return + } + + if (schema.length === 0) { + return + } + + candidate.forEach((entry, index) => { + const matchesAny = schema.some((schemaEntry) => { + const localIssues: ValidationIssue[] = [] + validateShape(schemaEntry, entry, [...path, { type: 'index', index }], localIssues) + return localIssues.length === 0 + }) + + if (!matchesAny) { + issues.push({ + path: formatPathTokens([...path, { type: 'index', index }]), + message: 'Array element does not match expected schema shape', + }) + } + }) + return + } + + if (isPlainObject(schema)) { + if (!isPlainObject(candidate)) { + issues.push({ + path: renderedPath, + message: `Expected object, got ${typeof candidate}`, + }) + return + } + + for (const key of Object.keys(candidate)) { + if (!(key in schema)) { + issues.push({ + path: formatPathTokens([...path, { type: 'key', key }]), + message: 'Unknown setting key', + }) + } + } + + for (const key of Object.keys(schema)) { + validateShape((schema as Record)[key], (candidate as Record)[key], [...path, { type: 'key', key }], issues) + } + + return + } + + if (candidate === null && schema !== null) { + issues.push({ + path: renderedPath, + message: `Expected ${typeof schema}, got null`, + }) + return + } + + if (schema !== null && typeof schema !== typeof candidate) { + issues.push({ + path: renderedPath, + message: `Expected ${typeof schema}, got ${typeof candidate}`, + }) + } +} + +const pathExistsInSchema = (schema: unknown, tokens: PathToken[]): boolean => { + let current: unknown = schema + + for (const token of tokens) { + if (token.type === 'key') { + if (!isPlainObject(current) || !(token.key in current)) { + return false + } + current = (current as Record)[token.key] + continue + } + + if (!Array.isArray(current)) { + return false + } + + current = current[0] + } + + return true +} + +export const ensureSettingsExists = (): void => { + const configDir = getConfigBaseDir() + const settingsPath = getSettingsFilePath() + const defaultsPath = getDefaultSettingsFilePath() + + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }) + } + + if (!fs.existsSync(settingsPath)) { + fs.copyFileSync(defaultsPath, settingsPath) + } +} + +export const loadDefaults = (): Settings => { + const defaultsRaw = fs.readFileSync(getDefaultSettingsFilePath(), 'utf-8') + return yaml.load(defaultsRaw) as Settings +} + +export const loadUserSettings = (): Settings => { + ensureSettingsExists() + const raw = fs.readFileSync(getSettingsFilePath(), 'utf-8') + return (yaml.load(raw) as Settings) ?? ({} as Settings) +} + +export const loadMergedSettings = (): Settings => { + return mergeDeepRight(loadDefaults(), loadUserSettings()) as Settings +} + +export const saveSettings = (settings: Settings): void => { + ensureSettingsExists() + const serialized = yaml.dump(toSerializable(settings), { lineWidth: 120 }) + fs.writeFileSync(getSettingsFilePath(), serialized, 'utf-8') +} + +export const getByPath = (settings: unknown, path: string): unknown => { + const tokens = parsePath(path) + let current: unknown = settings + + for (const token of tokens) { + if (token.type === 'key') { + if (!isPlainObject(current)) { + return undefined + } + current = current[token.key] + continue + } + + if (!Array.isArray(current)) { + return undefined + } + + current = current[token.index] + } + + return current +} + +const ensureArrayLength = (target: unknown[], minimumLength: number): void => { + while (target.length <= minimumLength) { + target.push(undefined) + } +} + +export const setByPath = (settings: Record, path: string, value: unknown): Record => { + const tokens = parsePath(path) + const clone: Record = structuredClone(settings) + + if (tokens.length === 0) { + throw new Error('Path is required') + } + + let current: unknown = clone + + for (let i = 0; i < tokens.length - 1; i++) { + const token = tokens[i] + const nextToken = tokens[i + 1] + + if (token.type === 'key') { + if (!isPlainObject(current)) { + throw new Error(`Cannot set key ${token.key} on non-object path`) + } + + const existing = current[token.key] + if (existing === undefined) { + current[token.key] = nextToken.type === 'index' ? [] : {} + } else if (nextToken.type === 'index' && !Array.isArray(existing)) { + current[token.key] = [] + } else if (nextToken.type === 'key' && !isPlainObject(existing)) { + current[token.key] = {} + } + + current = current[token.key] + continue + } + + if (!Array.isArray(current)) { + throw new Error(`Cannot index non-array path at [${token.index}]`) + } + + ensureArrayLength(current, token.index) + + const existing = current[token.index] + if (existing === undefined) { + current[token.index] = nextToken.type === 'index' ? [] : {} + } else if (nextToken.type === 'index' && !Array.isArray(existing)) { + current[token.index] = [] + } else if (nextToken.type === 'key' && !isPlainObject(existing)) { + current[token.index] = {} + } + + current = current[token.index] + } + + const last = tokens[tokens.length - 1] + + if (last.type === 'key') { + if (!isPlainObject(current)) { + throw new Error(`Cannot set key ${last.key} on non-object path`) + } + + current[last.key] = value + return clone + } + + if (!Array.isArray(current)) { + throw new Error(`Cannot index non-array path at [${last.index}]`) + } + + ensureArrayLength(current, last.index) + current[last.index] = value + + return clone +} + +export const validatePathAgainstDefaults = (path: string): ValidationIssue[] => { + const defaults = loadDefaults() as unknown + const tokens = parsePath(path) + + if (pathExistsInSchema(defaults, tokens)) { + return [] + } + + return [ + { + path, + message: 'Path does not exist in default settings schema', + }, + ] +} + +export const validateSettings = (settings: Settings): ValidationIssue[] => { + const issues: ValidationIssue[] = [] + + if (!settings.info?.relay_url) { + issues.push({ path: 'info.relay_url', message: 'relay_url is required' }) + } + + if (!settings.info?.name) { + issues.push({ path: 'info.name', message: 'name is required' }) + } + + if (!settings.network) { + issues.push({ path: 'network', message: 'network section is required' }) + } + + if (settings.payments?.enabled && !settings.payments.processor) { + issues.push({ path: 'payments.processor', message: 'processor is required when payments are enabled' }) + } + + const strategy = settings.limits?.rateLimiter?.strategy + if (strategy && strategy !== 'ewma' && strategy !== 'sliding_window') { + issues.push({ path: 'limits.rateLimiter.strategy', message: 'strategy must be ewma or sliding_window' }) + } + + validateShape(loadDefaults(), settings, [], issues) + + return issues +} diff --git a/src/cli/utils/docker.ts b/src/cli/utils/docker.ts new file mode 100644 index 00000000..8cf24fce --- /dev/null +++ b/src/cli/utils/docker.ts @@ -0,0 +1,47 @@ +import fs from 'fs' +import os from 'os' +import { join } from 'path' + +import { getProjectPath } from './paths' +import { runCommand } from './process' + +export type ComposeOptions = { + files: string[] + args: string[] + env?: NodeJS.ProcessEnv +} + +export const resolveComposeFile = (filename: string): string => getProjectPath(filename) + +export const buildComposeArgs = (files: string[], args: string[]): string[] => { + const out: string[] = [] + + for (const file of files) { + const fullPath = resolveComposeFile(file) + if (fs.existsSync(fullPath)) { + out.push('-f', fullPath) + } + } + + return [...out, ...args] +} + +export const runDockerCompose = async ({ files, args, env }: ComposeOptions): Promise => { + const composeArgs = buildComposeArgs(files, args) + return runCommand('docker', ['compose', ...composeArgs], { env }) +} + +export const createPortOverrideComposeFile = (port: number): string => { + const tempFile = join(os.tmpdir(), `nostream-port-override-${process.pid}-${Date.now()}.yml`) + const content = [ + 'services:', + ' nostream:', + ' environment:', + ` RELAY_PORT: ${port}`, + ' ports:', + ` - 127.0.0.1:${port}:${port}`, + ].join('\n') + + fs.writeFileSync(tempFile, content, { encoding: 'utf-8' }) + return tempFile +} diff --git a/src/cli/utils/env-config.ts b/src/cli/utils/env-config.ts new file mode 100644 index 00000000..fb4704c7 --- /dev/null +++ b/src/cli/utils/env-config.ts @@ -0,0 +1,215 @@ +import fs from 'fs' + +import { getEnvFilePath } from './paths' + +export type EnvValidationIssue = { + path: string + message: string +} + +type ParsedEnvLine = { + index: number + key: string + value: string +} + +const ENV_LINE_REGEX = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/ + +const SUPPORTED_ENV_KEYS = new Set([ + 'SECRET', + 'RELAY_PORT', + 'RELAY_PRIVATE_KEY', + 'WORKER_COUNT', + 'DB_URI', + 'DB_HOST', + 'DB_PORT', + 'DB_USER', + 'DB_PASSWORD', + 'DB_NAME', + 'DB_MIN_POOL_SIZE', + 'DB_MAX_POOL_SIZE', + 'DB_ACQUIRE_CONNECTION_TIMEOUT', + 'READ_REPLICA_ENABLED', + 'READ_REPLICAS', + 'TOR_HOST', + 'TOR_CONTROL_PORT', + 'TOR_PASSWORD', + 'HIDDEN_SERVICE_PORT', + 'REDIS_URI', + 'REDIS_HOST', + 'REDIS_PORT', + 'REDIS_USER', + 'REDIS_PASSWORD', + 'NOSTR_CONFIG_DIR', + 'DEBUG', + 'ZEBEDEE_API_KEY', + 'NODELESS_API_KEY', + 'NODELESS_WEBHOOK_SECRET', + 'OPENNODE_API_KEY', + 'LNBITS_API_KEY', + 'LOG_LEVEL', +]) + +const RR_KEY_REGEX = /^RR\d+_DB_(HOST|PORT|USER|PASSWORD|NAME|MIN_POOL_SIZE|MAX_POOL_SIZE|ACQUIRE_CONNECTION_TIMEOUT)$/ + +const INTEGER_KEYS = new Set([ + 'RELAY_PORT', + 'WORKER_COUNT', + 'DB_PORT', + 'DB_MIN_POOL_SIZE', + 'DB_MAX_POOL_SIZE', + 'DB_ACQUIRE_CONNECTION_TIMEOUT', + 'READ_REPLICAS', + 'TOR_CONTROL_PORT', + 'HIDDEN_SERVICE_PORT', + 'REDIS_PORT', +]) + +const BOOLEAN_KEYS = new Set(['READ_REPLICA_ENABLED']) + +const RR_INTEGER_KEY_REGEX = /^RR\d+_DB_(PORT|MIN_POOL_SIZE|MAX_POOL_SIZE|ACQUIRE_CONNECTION_TIMEOUT)$/ + +const SECRET_KEY_REGEX = /(SECRET|PASSWORD|API_KEY|PRIVATE_KEY)/i + +const parseEnvFile = (): { lines: string[]; parsed: ParsedEnvLine[] } => { + const envPath = getEnvFilePath() + + if (!fs.existsSync(envPath)) { + return { + lines: [], + parsed: [], + } + } + + const lines = fs.readFileSync(envPath, 'utf-8').split(/\r?\n/) + const parsed: ParsedEnvLine[] = [] + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] + if (!line || line.trim().startsWith('#')) { + continue + } + + const match = line.match(ENV_LINE_REGEX) + if (!match) { + continue + } + + parsed.push({ + index, + key: match[1], + value: match[2], + }) + } + + return { + lines, + parsed, + } +} + +export const isSupportedEnvKey = (key: string): boolean => { + return SUPPORTED_ENV_KEYS.has(key) || RR_KEY_REGEX.test(key) +} + +const validateInteger = (key: string, value: string): string | undefined => { + if (!/^-?\d+$/.test(value.trim())) { + return `${key} must be an integer` + } + + const parsed = Number(value) + if (!Number.isSafeInteger(parsed)) { + return `${key} must be a safe integer` + } + + return undefined +} + +const validateBoolean = (key: string, value: string): string | undefined => { + const normalized = value.trim().toLowerCase() + if (normalized === 'true' || normalized === 'false') { + return undefined + } + + return `${key} must be true or false` +} + +export const validateEnvPair = (key: string, value: string): string | undefined => { + if (!isSupportedEnvKey(key)) { + return `${key} is not a supported environment setting` + } + + if (INTEGER_KEYS.has(key) || RR_INTEGER_KEY_REGEX.test(key)) { + return validateInteger(key, value) + } + + if (BOOLEAN_KEYS.has(key)) { + return validateBoolean(key, value) + } + + return undefined +} + +export const readEnvValues = (): Record => { + const { parsed } = parseEnvFile() + const values: Record = {} + + for (const line of parsed) { + values[line.key] = line.value + } + + return values +} + +export const upsertEnvValue = (key: string, value: string): void => { + const envPath = getEnvFilePath() + const { lines, parsed } = parseEnvFile() + + const existing = parsed.find((line) => line.key === key) + const replacement = `${key}=${value}` + + if (existing) { + lines[existing.index] = replacement + } else { + if (lines.length > 0 && lines[lines.length - 1].trim() !== '') { + lines.push('') + } + lines.push(replacement) + } + + fs.writeFileSync(envPath, lines.join('\n').replace(/\n?$/, '\n'), 'utf-8') +} + +export const validateEnvValues = (values: Record): EnvValidationIssue[] => { + const issues: EnvValidationIssue[] = [] + + for (const [key, value] of Object.entries(values)) { + const issue = validateEnvPair(key, value) + if (!issue) { + continue + } + + issues.push({ + path: key, + message: issue, + }) + } + + return issues +} + +export const isSecretEnvKey = (key: string): boolean => { + return SECRET_KEY_REGEX.test(key) +} + +export const maskSecretValue = (value: string): string => { + if (!value) { + return '***' + } + + if (value.length <= 4) { + return '*'.repeat(value.length) + } + + return `${value.slice(0, 2)}***${value.slice(-2)}` +} diff --git a/src/cli/utils/formatting.ts b/src/cli/utils/formatting.ts new file mode 100644 index 00000000..d54fe852 --- /dev/null +++ b/src/cli/utils/formatting.ts @@ -0,0 +1,3 @@ +export const formatJson = (value: unknown): string => JSON.stringify(value, null, 2) + +export const formatKeyValue = (key: string, value: string): string => `${key}: ${value}` diff --git a/src/cli/utils/output.ts b/src/cli/utils/output.ts new file mode 100644 index 00000000..1dcf0210 --- /dev/null +++ b/src/cli/utils/output.ts @@ -0,0 +1,31 @@ +import { bold, cyan, red, yellow, green } from 'colorette' + +const writeStdout = (message: string): void => { + process.stdout.write(`${message}\n`) +} + +const writeStderr = (message: string): void => { + process.stderr.write(`${message}\n`) +} + +export const logStep = (message: string): void => { + writeStdout(cyan(`• ${message}`)) +} + +export const logInfo = (message: string): void => { + writeStdout(message) +} + +export const logSuccess = (message: string): void => { + writeStdout(green(message)) +} + +export const logWarn = (message: string): void => { + writeStderr(yellow(message)) +} + +export const logError = (message: string): void => { + writeStderr(red(message)) +} + +export const title = (label: string): string => bold(label) diff --git a/src/cli/utils/paths.ts b/src/cli/utils/paths.ts new file mode 100644 index 00000000..33f2673d --- /dev/null +++ b/src/cli/utils/paths.ts @@ -0,0 +1,13 @@ +import { join } from 'path' + +export const getProjectRoot = (): string => process.cwd() + +export const getProjectPath = (...parts: string[]): string => join(getProjectRoot(), ...parts) + +export const getConfigBaseDir = (): string => process.env.NOSTR_CONFIG_DIR ?? getProjectPath('.nostr') + +export const getSettingsFilePath = (): string => join(getConfigBaseDir(), 'settings.yaml') + +export const getDefaultSettingsFilePath = (): string => getProjectPath('resources', 'default-settings.yaml') + +export const getEnvFilePath = (): string => getProjectPath('.env') diff --git a/src/cli/utils/process.ts b/src/cli/utils/process.ts new file mode 100644 index 00000000..cfa702f9 --- /dev/null +++ b/src/cli/utils/process.ts @@ -0,0 +1,56 @@ +import { spawn } from 'child_process' + +export type RunOptions = { + cwd?: string + env?: NodeJS.ProcessEnv + stdio?: 'inherit' | 'pipe' +} + +export const runCommand = (command: string, args: string[], options: RunOptions = {}): Promise => { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: { ...process.env, ...(options.env ?? {}) }, + stdio: options.stdio ?? 'inherit', + shell: false, + }) + + child.on('error', reject) + child.on('close', (code) => resolve(code ?? 1)) + }) +} + +export const runCommandWithOutput = ( + command: string, + args: string[], + options: RunOptions = {}, +): Promise<{ code: number; stdout: string; stderr: string }> => { + return new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + + const child = spawn(command, args, { + cwd: options.cwd, + env: { ...process.env, ...(options.env ?? {}) }, + stdio: 'pipe', + shell: false, + }) + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString() + }) + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString() + }) + + child.on('error', reject) + child.on('close', (code) => { + resolve({ + code: code ?? 1, + stdout, + stderr, + }) + }) + }) +} diff --git a/src/cli/utils/validation.ts b/src/cli/utils/validation.ts new file mode 100644 index 00000000..b87e3fa0 --- /dev/null +++ b/src/cli/utils/validation.ts @@ -0,0 +1,11 @@ +export const requireTruthy = (value: unknown, message: string): void => { + if (!value) { + throw new Error(message) + } +} + +export const requirePositiveInteger = (value: number, label: string): void => { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer`) + } +} diff --git a/test/unit/cli/cli.integration.spec.ts b/test/unit/cli/cli.integration.spec.ts new file mode 100644 index 00000000..5865afeb --- /dev/null +++ b/test/unit/cli/cli.integration.spec.ts @@ -0,0 +1,356 @@ +import { expect } from 'chai' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { spawn } from 'child_process' + +const projectRoot = process.cwd() + +type CliResult = { + code: number + stdout: string + stderr: string +} + +const runCli = (args: string[], env: NodeJS.ProcessEnv = {}): Promise => { + return new Promise((resolve, reject) => { + const child = spawn('node', ['dist/src/cli/index.js', ...args], { + cwd: projectRoot, + env: { + ...process.env, + ...env, + }, + stdio: 'pipe', + }) + + let stdout = '' + let stderr = '' + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString() + }) + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString() + }) + + child.on('error', reject) + child.on('close', (code) => { + resolve({ + code: code ?? 1, + stdout, + stderr, + }) + }) + }) +} + +const createShimCommand = (dir: string, name: string, scriptBody: string) => { + const target = path.join(dir, name) + fs.writeFileSync( + target, + ['#!/usr/bin/env bash', 'set -euo pipefail', scriptBody].join('\n'), + 'utf-8', + ) + fs.chmodSync(target, 0o755) +} + +describe('cli integration (spawn)', function () { + this.timeout(30000) + + it('shows top-level help', async () => { + const result = await runCli(['--help']) + + expect(result.code).to.equal(0) + expect(result.stdout).to.include('Usage:') + expect(result.stdout).to.include('config [...args]') + expect(result.stdout).to.include('update [...args]') + expect(result.stdout).to.include('clean') + }) + + it('keeps package bin mapping aligned with TypeScript build output path', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8')) as { + bin?: string | { nostream?: string } + } + const binPath = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.nostream + expect(binPath).to.equal('./dist/src/cli/index.js') + }) + + it('shows nested subcommand help', async () => { + const configGet = await runCli(['config', 'get', '--help']) + const devClean = await runCli(['dev', 'db:clean', '--help']) + + expect(configGet.code).to.equal(0) + expect(configGet.stdout).to.include('Usage: nostream config get ') + + expect(devClean.code).to.equal(0) + expect(devClean.stdout).to.include('Usage: nostream dev db:clean') + }) + + it('returns usage exit code for unknown command', async () => { + const result = await runCli(['nope']) + + expect(result.code).to.equal(2) + expect(result.stdout).to.include('Usage:') + }) + + it('prints help and exits 0 with no args in non-interactive mode', async () => { + const result = await runCli([]) + + expect(result.code).to.equal(0) + expect(result.stdout).to.include('Usage:') + }) + + it('supports config set/get with indexed path and validation controls', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-config-')) + + const setIndexed = await runCli( + ['config', 'set', 'limits.event.content[0].maxLength', '2048'], + { NOSTR_CONFIG_DIR: configDir }, + ) + expect(setIndexed.code).to.equal(0) + + const getIndexed = await runCli( + ['config', 'get', 'limits.event.content[0].maxLength'], + { NOSTR_CONFIG_DIR: configDir }, + ) + expect(getIndexed.code).to.equal(0) + expect(getIndexed.stdout).to.include('2048') + + const setInvalidValidated = await runCli( + ['config', 'set', 'limits.rateLimiter.strategy', 'broken-strategy'], + { NOSTR_CONFIG_DIR: configDir }, + ) + expect(setInvalidValidated.code).to.equal(1) + + const getStrategyAfterReject = await runCli( + ['config', 'get', 'limits.rateLimiter.strategy'], + { NOSTR_CONFIG_DIR: configDir }, + ) + expect(getStrategyAfterReject.code).to.equal(0) + expect(getStrategyAfterReject.stdout).to.include('ewma') + + const setInvalidNoValidate = await runCli( + ['config', 'set', 'limits.rateLimiter.strategy', 'broken-strategy', '--no-validate'], + { NOSTR_CONFIG_DIR: configDir }, + ) + expect(setInvalidNoValidate.code).to.equal(0) + + const getStrategyAfterNoValidate = await runCli( + ['config', 'get', 'limits.rateLimiter.strategy'], + { NOSTR_CONFIG_DIR: configDir }, + ) + expect(getStrategyAfterNoValidate.code).to.equal(0) + expect(getStrategyAfterNoValidate.stdout).to.include('broken-strategy') + }) + + it('supports config set JSON mode', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-json-')) + + const setResult = await runCli( + ['config', 'set', 'nip05.domainWhitelist', '["example.com","relay.io"]', '--type', 'json'], + { NOSTR_CONFIG_DIR: configDir }, + ) + + expect(setResult.code).to.equal(0) + + const getResult = await runCli( + ['config', 'get', 'nip05.domainWhitelist'], + { NOSTR_CONFIG_DIR: configDir }, + ) + + expect(getResult.code).to.equal(0) + expect(getResult.stdout).to.include('example.com') + }) + + it('supports import/export aliases and format flags in help', async () => { + const importHelp = await runCli(['import', '--help']) + const exportHelp = await runCli(['export', '--help']) + const startHelp = await runCli(['start', '--help']) + const infoHelp = await runCli(['info', '--help']) + + expect(importHelp.code).to.equal(0) + expect(importHelp.stdout).to.include('--file ') + expect(importHelp.stdout).to.include('Path to .jsonl/.json file') + + expect(exportHelp.code).to.equal(0) + expect(exportHelp.stdout).to.include('--output ') + expect(exportHelp.stdout).to.include('--compress') + expect(exportHelp.stdout).to.include('--format ') + expect(exportHelp.stdout).to.include('jsonl|json|gzip|gz|xz') + + expect(startHelp.code).to.equal(0) + expect(startHelp.stdout).to.include('--nginx') + + expect(infoHelp.code).to.equal(0) + expect(infoHelp.stdout).to.include('--i2p-hostname') + }) + + it('validates nginx start requirements', async () => { + const result = await runCli(['start', '--nginx']) + + expect(result.code).to.equal(1) + expect(result.stderr).to.include('RELAY_DOMAIN environment variable is required when using --nginx') + }) + + it('returns usage exit code for unsupported/unknown format flags', async () => { + const importResult = await runCli(['import', '--format', 'yaml']) + const exportResult = await runCli(['export', '--format', 'yaml']) + const conflictingExportResult = await runCli(['export', '--format', 'json', '--compress']) + + expect(importResult.code).to.equal(1) + expect(importResult.stderr).to.include('Unknown option `--format`') + + expect(exportResult.code).to.equal(2) + expect(exportResult.stderr).to.include('Error: Unsupported format: yaml. Supported values: json, jsonl, gzip, gz, xz') + expect(exportResult.stderr).to.include('Unsupported format: yaml') + + expect(conflictingExportResult.code).to.equal(2) + expect(conflictingExportResult.stderr).to.include('Cannot combine --compress with --format json/jsonl') + }) + + it('rejects out-of-range start port values', async () => { + const result = await runCli(['start', '--port', '70000']) + + expect(result.code).to.equal(1) + expect(result.stderr).to.include('Port must be a safe integer between 1 and 65535') + }) + + it('invokes docker compose stack through start command using shims', async () => { + const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-shim-docker-')) + const logPath = path.join(shimDir, 'docker.log') + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-shim-config-')) + + createShimCommand( + shimDir, + 'docker', + [ + `echo "$*" >> "${logPath}"`, + 'exit 0', + ].join('\n'), + ) + + const result = await runCli(['start', '--tor', '--i2p', '--debug'], { + PATH: `${shimDir}:${process.env.PATH}`, + NOSTR_CONFIG_DIR: configDir, + }) + + expect(result.code).to.equal(0) + + const logs = fs.readFileSync(logPath, 'utf-8') + expect(logs).to.include('compose') + expect(logs).to.include('docker-compose.tor.yml') + expect(logs).to.include('docker-compose.i2p.yml') + expect(logs).to.include('up --build --remove-orphans') + }) + + it('cleans temporary port override compose files after start', async () => { + const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-shim-port-')) + const logPath = path.join(shimDir, 'docker.log') + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-shim-port-config-')) + + createShimCommand( + shimDir, + 'docker', + [ + `echo "$*" >> "${logPath}"`, + 'exit 0', + ].join('\n'), + ) + + const before = fs + .readdirSync(os.tmpdir()) + .filter((name) => name.startsWith('nostream-port-override-') && name.endsWith('.yml')).length + + const result = await runCli(['start', '--port', '9999'], { + PATH: `${shimDir}:${process.env.PATH}`, + NOSTR_CONFIG_DIR: configDir, + }) + + const after = fs + .readdirSync(os.tmpdir()) + .filter((name) => name.startsWith('nostream-port-override-') && name.endsWith('.yml')).length + + expect(result.code).to.equal(0) + expect(after).to.equal(before) + }) + + it('supports config env subcommand help', async () => { + const result = await runCli(['config', 'env', '--help']) + + expect(result.code).to.equal(0) + expect(result.stdout).to.include('Usage: nostream config env ') + }) + + it('runs legacy clean replacement command', async () => { + const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-shim-clean-')) + const logPath = path.join(shimDir, 'docker.log') + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-shim-clean-config-')) + + createShimCommand( + shimDir, + 'docker', + [ + `echo "$*" >> "${logPath}"`, + 'exit 0', + ].join('\n'), + ) + + const result = await runCli(['clean'], { + PATH: `${shimDir}:${process.env.PATH}`, + NOSTR_CONFIG_DIR: configDir, + }) + + expect(result.code).to.equal(0) + + const logs = fs.readFileSync(logPath, 'utf-8') + expect(logs).to.include('compose') + expect(logs).to.include('down') + expect(logs).to.include('system prune -a -f') + expect(logs).to.include('volume prune -f') + }) + + it('runs legacy update replacement command', async () => { + const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-shim-update-')) + const dockerLogPath = path.join(shimDir, 'docker.log') + const gitLogPath = path.join(shimDir, 'git.log') + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-shim-update-config-')) + + createShimCommand( + shimDir, + 'docker', + [ + `echo "$*" >> "${dockerLogPath}"`, + 'exit 0', + ].join('\n'), + ) + + createShimCommand( + shimDir, + 'git', + [ + `echo "$*" >> "${gitLogPath}"`, + 'if [[ "$1" == "stash" && "$2" == "push" ]]; then', + ' echo "No local changes to save"', + 'fi', + 'exit 0', + ].join('\n'), + ) + + const result = await runCli(['update'], { + PATH: `${shimDir}:${process.env.PATH}`, + NOSTR_CONFIG_DIR: configDir, + }) + + expect(result.code).to.equal(0) + + const dockerLogs = fs.readFileSync(dockerLogPath, 'utf-8') + expect(dockerLogs).to.include('compose') + expect(dockerLogs).to.include('down') + expect(dockerLogs).to.include('up --build --remove-orphans') + + const gitLogs = fs.readFileSync(gitLogPath, 'utf-8') + expect(gitLogs).to.include('stash push -u -m nostream-cli-update') + expect(gitLogs).to.include('pull') + }) +}) diff --git a/test/unit/cli/commands.spec.ts b/test/unit/cli/commands.spec.ts new file mode 100644 index 00000000..adc4dfaf --- /dev/null +++ b/test/unit/cli/commands.spec.ts @@ -0,0 +1,18 @@ +import { expect } from 'chai' + +import { isSupportedEnvKey, validateEnvPair } from '../../../src/cli/utils/env-config' + +describe('cli env config helpers', () => { + it('accepts supported env keys', () => { + expect(isSupportedEnvKey('RELAY_PORT')).to.equal(true) + expect(isSupportedEnvKey('RR0_DB_HOST')).to.equal(true) + expect(isSupportedEnvKey('UNKNOWN_KEY')).to.equal(false) + }) + + it('validates numeric and boolean env values', () => { + expect(validateEnvPair('RELAY_PORT', '8008')).to.equal(undefined) + expect(validateEnvPair('RELAY_PORT', 'bad')).to.include('must be an integer') + expect(validateEnvPair('READ_REPLICA_ENABLED', 'true')).to.equal(undefined) + expect(validateEnvPair('READ_REPLICA_ENABLED', 'yes')).to.include('must be true or false') + }) +}) diff --git a/test/unit/cli/config.spec.ts b/test/unit/cli/config.spec.ts new file mode 100644 index 00000000..e65e678c --- /dev/null +++ b/test/unit/cli/config.spec.ts @@ -0,0 +1,78 @@ +import { expect } from 'chai' + +import { + getByPath, + parseTypedValue, + parseValue, + setByPath, + validatePathAgainstDefaults, + validateSettings, +} from '../../../src/cli/utils/config' + +describe('cli config utils', () => { + it('parses primitive values', () => { + expect(parseValue('true')).to.equal(true) + expect(parseValue('false')).to.equal(false) + expect(parseValue('42')).to.equal(42) + expect(parseValue('42n')).to.equal(42n) + expect(parseValue('null')).to.equal(null) + expect(parseValue('hello')).to.equal('hello') + }) + + it('parses typed json values', () => { + expect(parseTypedValue('{"enabled":true}', 'json')).to.deep.equal({ enabled: true }) + expect(parseTypedValue('[1,2,3]', 'json')).to.deep.equal([1, 2, 3]) + expect(() => parseTypedValue('{', 'json')).to.throw('Invalid JSON value') + }) + + it('sets and gets dot-path values', () => { + const input = { + payments: { + enabled: false, + }, + } + + const updated = setByPath(input as any, 'payments.enabled', true) + + expect(getByPath(updated, 'payments.enabled')).to.equal(true) + expect(getByPath(updated, 'payments')).to.deep.equal({ enabled: true }) + expect(getByPath(updated, 'payments.processor')).to.equal(undefined) + }) + + it('supports indexed path syntax', () => { + const input = { + limits: { + event: { + content: [ + { + maxLength: 100, + }, + ], + }, + }, + } + + const updated = setByPath(input as any, 'limits.event.content[0].maxLength', 500) + + expect(getByPath(updated, 'limits.event.content[0].maxLength')).to.equal(500) + }) + + it('rejects malformed path syntax', () => { + expect(() => setByPath({} as any, 'payments[]', true)).to.throw('Invalid path segment') + }) + + it('validates known paths against defaults', () => { + expect(validatePathAgainstDefaults('payments.enabled')).to.deep.equal([]) + expect(validatePathAgainstDefaults('limits.event.content[0].maxLength')).to.deep.equal([]) + + const issues = validatePathAgainstDefaults('payments.fakeField') + expect(issues[0].message).to.include('does not exist') + }) + + it('validates basic required fields', () => { + const issues = validateSettings({} as any) + + expect(issues.some((issue) => issue.path === 'info.relay_url')).to.equal(true) + expect(issues.some((issue) => issue.path === 'network')).to.equal(true) + }) +}) diff --git a/test/unit/cli/docker.spec.ts b/test/unit/cli/docker.spec.ts new file mode 100644 index 00000000..964b257d --- /dev/null +++ b/test/unit/cli/docker.spec.ts @@ -0,0 +1,34 @@ +import { expect } from 'chai' +import fs from 'fs' +import os from 'os' +import path from 'path' +import sinon from 'sinon' + +import { buildComposeArgs, createPortOverrideComposeFile } from '../../../src/cli/utils/docker' + +describe('cli docker utils', () => { + afterEach(() => { + sinon.restore() + }) + + it('includes only existing compose files', () => { + const existsSyncStub = sinon.stub(fs, 'existsSync').callsFake((input) => String(input).includes('docker-compose.yml')) + + const args = buildComposeArgs(['docker-compose.yml', 'docker-compose.tor.yml'], ['up']) + + expect(existsSyncStub.called).to.equal(true) + expect(args).to.include('up') + expect(args).to.include(path.join(process.cwd(), 'docker-compose.yml')) + expect(args).to.not.include(path.join(process.cwd(), 'docker-compose.tor.yml')) + }) + + it('creates a temporary port override compose file', () => { + const tempFile = createPortOverrideComposeFile(9999) + const content = fs.readFileSync(tempFile, 'utf-8') + + expect(tempFile.startsWith(os.tmpdir())).to.equal(true) + expect(content).to.include('127.0.0.1:9999:9999') + + fs.unlinkSync(tempFile) + }) +}) diff --git a/test/unit/cli/export-command.spec.ts b/test/unit/cli/export-command.spec.ts new file mode 100644 index 00000000..8a6f1a9e --- /dev/null +++ b/test/unit/cli/export-command.spec.ts @@ -0,0 +1,46 @@ +import { expect } from 'chai' +import sinon from 'sinon' + +import * as exportEventsModule from '../../../src/scripts/export-events' +import { runExport } from '../../../src/cli/commands/export' + +describe('runExport command adapter', () => { + afterEach(() => { + sinon.restore() + }) + + it('forwards legacy compression flags to export-events runtime', async () => { + const runExportEventsStub = sinon.stub(exportEventsModule, 'runExportEvents').resolves(0) + + const code = await runExport( + { + output: 'backup.jsonl.gz', + compress: true, + compressionFormat: 'gzip', + }, + [], + ) + + expect(code).to.equal(0) + expect(runExportEventsStub.calledOnce).to.equal(true) + expect(runExportEventsStub.firstCall.args[0]).to.deep.equal(['backup.jsonl.gz', '--compress', '--format', 'gzip']) + expect(runExportEventsStub.firstCall.args[1]).to.deep.equal({ format: undefined }) + }) + + it('keeps structured format in options while removing handled raw args', async () => { + const runExportEventsStub = sinon.stub(exportEventsModule, 'runExportEvents').resolves(0) + + const code = await runExport( + { + output: 'backup.json', + format: 'json', + }, + ['--format', 'json', '--compress', '-z', '--unknown-flag'], + ) + + expect(code).to.equal(0) + expect(runExportEventsStub.calledOnce).to.equal(true) + expect(runExportEventsStub.firstCall.args[0]).to.deep.equal(['backup.json', '--unknown-flag']) + expect(runExportEventsStub.firstCall.args[1]).to.deep.equal({ format: 'json' }) + }) +}) diff --git a/test/unit/cli/export.spec.ts b/test/unit/cli/export.spec.ts new file mode 100644 index 00000000..f4194b85 --- /dev/null +++ b/test/unit/cli/export.spec.ts @@ -0,0 +1,117 @@ +import { expect } from 'chai' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { Readable } from 'stream' +import sinon from 'sinon' + +import * as dbClient from '../../../src/database/client' +import { runExportEvents } from '../../../src/scripts/export-events' + +type EventRow = { + event_id: Buffer + event_pubkey: Buffer + event_kind: number + event_created_at: number + event_content: string + event_tags: unknown[] | null + event_signature: Buffer +} + +const createRow = (idHex: string, createdAt: number): EventRow => ({ + event_id: Buffer.from(idHex, 'hex'), + event_pubkey: Buffer.from('11'.repeat(32), 'hex'), + event_kind: 1, + event_created_at: createdAt, + event_content: `event-${createdAt}`, + event_tags: [['p', 'abc']], + event_signature: Buffer.from('22'.repeat(64), 'hex'), +}) + +const createMockDb = (rows: EventRow[]) => { + const makeQuery = () => ({ + select() { + return this + }, + whereNull() { + return this + }, + orderBy() { + return this + }, + first: async () => (rows[0] ? { event_id: rows[0].event_id } : undefined), + stream: () => Readable.from(rows), + }) + + const db = ((table: string) => { + if (table !== 'events') { + throw new Error(`Unexpected table: ${table}`) + } + + return makeQuery() + }) as unknown as ((table: string) => ReturnType) & { destroy: () => Promise } + + db.destroy = async () => {} + return db +} + +describe('cli export formats', () => { + afterEach(() => { + sinon.restore() + }) + + it('exports JSON array format when --format json is selected', async () => { + const rows = [createRow('aa'.repeat(32), 100), createRow('bb'.repeat(32), 200)] + sinon.stub(dbClient, 'getMasterDbClient').returns(createMockDb(rows) as any) + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-export-json-')) + const outputPath = path.join(tempDir, 'events.json') + + const code = await runExportEvents([outputPath], { format: 'json' }) + expect(code).to.equal(0) + + const fileContent = fs.readFileSync(outputPath, 'utf-8') + const parsed = JSON.parse(fileContent) as Array<{ id: string; kind: number }> + expect(parsed).to.have.length(2) + expect(parsed[0].id).to.equal('aa'.repeat(32)) + expect(parsed[1].id).to.equal('bb'.repeat(32)) + expect(parsed[0].kind).to.equal(1) + }) + + it('exports JSON Lines format by default', async () => { + const rows = [createRow('cc'.repeat(32), 300)] + sinon.stub(dbClient, 'getMasterDbClient').returns(createMockDb(rows) as any) + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-export-jsonl-')) + const outputPath = path.join(tempDir, 'events.jsonl') + + const code = await runExportEvents([outputPath], { format: 'jsonl' }) + expect(code).to.equal(0) + + const lines = fs + .readFileSync(outputPath, 'utf-8') + .trim() + .split('\n') + .filter(Boolean) + + expect(lines).to.have.length(1) + const first = JSON.parse(lines[0]) as { id: string } + expect(first.id).to.equal('cc'.repeat(32)) + }) + + it('rejects mismatched output extension for selected format', async () => { + const rows = [createRow('dd'.repeat(32), 400)] + sinon.stub(dbClient, 'getMasterDbClient').returns(createMockDb(rows) as any) + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-export-invalid-ext-')) + const outputPath = path.join(tempDir, 'events.json') + + try { + await runExportEvents([outputPath], { format: 'jsonl' }) + expect.fail('Expected runExportEvents to throw for mismatched extension') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + expect(message).to.include('Output file extension must be .jsonl when using --format jsonl') + } + }) +}) diff --git a/test/unit/cli/import.runtime.spec.ts b/test/unit/cli/import.runtime.spec.ts new file mode 100644 index 00000000..26af1772 --- /dev/null +++ b/test/unit/cli/import.runtime.spec.ts @@ -0,0 +1,86 @@ +import { expect } from 'chai' +import fs from 'fs' +import os from 'os' +import path from 'path' +import sinon from 'sinon' + +import { runImportEvents } from '../../../src/import-events' +import * as dbClient from '../../../src/database/client' +import { EventImportService, EventImportStats } from '../../../src/services/event-import-service' + +const makeTempFile = (name: string, content: string): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-import-runtime-')) + const filePath = path.join(dir, name) + fs.writeFileSync(filePath, content, 'utf-8') + return filePath +} + +const stubDb = () => { + return { + destroy: sinon.stub().resolves(), + } as any +} + +const emptyStats = (): EventImportStats => ({ + errors: 0, + inserted: 0, + processed: 0, + skipped: 0, +}) + +describe('import runtime routing', () => { + afterEach(() => { + sinon.restore() + }) + + it('routes .jsonl input to importFromJsonl', async () => { + const filePath = makeTempFile('events.jsonl', '{"x":1}\n') + + sinon.stub(dbClient, 'getMasterDbClient').returns(stubDb()) + const jsonlStub = sinon.stub(EventImportService.prototype, 'importFromReadable').resolves(emptyStats()) + const jsonArrayStub = sinon.stub(EventImportService.prototype, 'importFromJsonArray').resolves(emptyStats()) + + const code = await runImportEvents([filePath]) + + expect(code).to.equal(0) + expect(jsonlStub.calledOnce).to.equal(true) + expect(jsonArrayStub.called).to.equal(false) + }) + + it('routes .json input to importFromJsonArray', async () => { + const filePath = makeTempFile('events.json', '[]') + + sinon.stub(dbClient, 'getMasterDbClient').returns(stubDb()) + const jsonlStub = sinon.stub(EventImportService.prototype, 'importFromJsonl').resolves(emptyStats()) + const jsonArrayStub = sinon.stub(EventImportService.prototype, 'importFromJsonArray').resolves(emptyStats()) + + const code = await runImportEvents([filePath]) + + expect(code).to.equal(0) + expect(jsonArrayStub.calledOnce).to.equal(true) + expect(jsonlStub.called).to.equal(false) + }) + + it('rejects unsupported input extensions', async () => { + const filePath = makeTempFile('events.txt', '') + + try { + await runImportEvents([filePath]) + expect.fail('Expected unsupported extension to throw') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + expect(message).to.include('Input file must have a .jsonl or .json extension') + } + }) + + it('prints help with .json and .jsonl usage', async () => { + const logStub = sinon.stub(console, 'log') + + const code = await runImportEvents(['--help']) + + expect(code).to.equal(0) + const output = logStub.getCalls().map((call) => call.args.join(' ')).join('\n') + expect(output).to.include('file.jsonl|file.json') + expect(output).to.include('nostream import ./events.json --batch-size 1000') + }) +}) diff --git a/test/unit/cli/tui.spec.ts b/test/unit/cli/tui.spec.ts new file mode 100644 index 00000000..6656e9b9 --- /dev/null +++ b/test/unit/cli/tui.spec.ts @@ -0,0 +1,147 @@ +import { expect } from 'chai' +import sinon from 'sinon' + +import * as configCommands from '../../../src/cli/commands/config' +import * as exportCommand from '../../../src/cli/commands/export' +import * as importCommand from '../../../src/cli/commands/import' +import * as startCommand from '../../../src/cli/commands/start' +import * as configureMenu from '../../../src/cli/tui/menus/configure' +import * as devMenu from '../../../src/cli/tui/menus/dev' +import * as manageMenu from '../../../src/cli/tui/menus/manage' +import * as startMenu from '../../../src/cli/tui/menus/start' +import { tuiPrompts } from '../../../src/cli/tui/prompts' + +describe('cli tui menus', () => { + afterEach(() => { + sinon.restore() + }) + + it('routes configure list action', async () => { + sinon.stub(tuiPrompts, 'select').resolves('list' as any) + sinon.stub(tuiPrompts, 'isCancel').returns(false) + const runList = sinon.stub(configCommands, 'runConfigList').resolves(0) + + const code = await configureMenu.runConfigureMenu() + + expect(code).to.equal(0) + expect(runList.calledOnceWithExactly()).to.equal(true) + }) + + it('handles configure cancellation', async () => { + sinon.stub(tuiPrompts, 'select').resolves(Symbol('cancel') as any) + sinon.stub(tuiPrompts, 'isCancel').returns(true) + + const code = await configureMenu.runConfigureMenu() + + expect(code).to.equal(1) + }) + + it('returns to previous menu on configure back selection', async () => { + sinon.stub(tuiPrompts, 'select').resolves('back' as any) + sinon.stub(tuiPrompts, 'isCancel').returns(false) + + const code = await configureMenu.runConfigureMenu() + + expect(code).to.equal(0) + }) + + it('routes start menu prompt values into start command', async () => { + sinon.stub(tuiPrompts, 'select').resolves('continue' as any) + sinon + .stub(tuiPrompts, 'confirm') + .onFirstCall() + .resolves(true as any) + .onSecondCall() + .resolves(false as any) + .onThirdCall() + .resolves(true as any) + .onCall(3) + .resolves(false as any) + .onCall(4) + .resolves(true as any) + sinon.stub(tuiPrompts, 'isCancel').returns(false) + + const runStart = sinon.stub(startCommand, 'runStart').resolves(0) + + const code = await startMenu.runStartMenu() + + expect(code).to.equal(0) + expect(runStart.calledOnce).to.equal(true) + expect(runStart.firstCall.args[0]).to.deep.equal({ + tor: true, + i2p: false, + debug: true, + port: undefined, + }) + }) + + it('returns to previous menu on start back selection', async () => { + sinon.stub(tuiPrompts, 'select').resolves('back' as any) + sinon.stub(tuiPrompts, 'isCancel').returns(false) + + const code = await startMenu.runStartMenu() + + expect(code).to.equal(0) + }) + + it('maps manage export format selection to export file format', async () => { + sinon + .stub(tuiPrompts, 'select') + .onFirstCall() + .resolves('export' as any) + .onSecondCall() + .resolves('json' as any) + sinon.stub(tuiPrompts, 'text').resolves('events.json' as any) + sinon.stub(tuiPrompts, 'confirm').resolves(true as any) + sinon.stub(tuiPrompts, 'isCancel').returns(false) + + const runExport = sinon.stub(exportCommand, 'runExport').resolves(0) + + const code = await manageMenu.runManageMenu() + + expect(code).to.equal(0) + expect(runExport.calledOnceWithExactly({ output: 'events.json', format: 'json' }, [])).to.equal(true) + }) + + it('maps manage import format selection to import file defaults', async () => { + sinon + .stub(tuiPrompts, 'select') + .onFirstCall() + .resolves('import' as any) + .onSecondCall() + .resolves('json' as any) + sinon + .stub(tuiPrompts, 'text') + .onFirstCall() + .resolves('events.json' as any) + .onSecondCall() + .resolves('500' as any) + sinon.stub(tuiPrompts, 'confirm').resolves(true as any) + sinon.stub(tuiPrompts, 'isCancel').returns(false) + + const runImport = sinon.stub(importCommand, 'runImport').resolves(0) + + const code = await manageMenu.runManageMenu() + + expect(code).to.equal(0) + expect(runImport.calledOnceWithExactly({ file: 'events.json', batchSize: 500 }, [])).to.equal(true) + }) + + it('returns to previous menu on manage back selection', async () => { + sinon.stub(tuiPrompts, 'select').resolves('back' as any) + sinon.stub(tuiPrompts, 'isCancel').returns(false) + + const code = await manageMenu.runManageMenu() + + expect(code).to.equal(0) + }) + + it('returns to previous menu on dev back selection', async () => { + sinon.stub(tuiPrompts, 'select').resolves('back' as any) + sinon.stub(tuiPrompts, 'isCancel').returns(false) + + const code = await devMenu.runDevMenu() + + expect(code).to.equal(0) + }) +}) diff --git a/test/unit/cli/update.spec.ts b/test/unit/cli/update.spec.ts new file mode 100644 index 00000000..6c5d9308 --- /dev/null +++ b/test/unit/cli/update.spec.ts @@ -0,0 +1,59 @@ +import { expect } from 'chai' +import sinon from 'sinon' + +import { runUpdate } from '../../../src/cli/commands/update' +import * as processUtils from '../../../src/cli/utils/process' +import * as startCommand from '../../../src/cli/commands/start' +import * as stopCommand from '../../../src/cli/commands/stop' + +describe('runUpdate', () => { + afterEach(() => { + sinon.restore() + }) + + it('attempts to restore stash when pull fails and stash was created', async () => { + sinon.stub(stopCommand, 'runStop').resolves(0) + const runStartStub = sinon.stub(startCommand, 'runStart').resolves(0) + sinon.stub(processUtils, 'runCommandWithOutput').resolves({ + code: 0, + stdout: 'Saved working directory and index state WIP on main: abc123', + stderr: '', + }) + const runCommandStub = sinon + .stub(processUtils, 'runCommand') + .onFirstCall() + .resolves(1) + .onSecondCall() + .resolves(0) + + const code = await runUpdate([]) + + expect(code).to.equal(1) + expect(runCommandStub.firstCall.args).to.deep.equal(['git', ['pull']]) + expect(runCommandStub.secondCall.args).to.deep.equal(['git', ['stash', 'pop']]) + expect(runStartStub.called).to.equal(false) + }) + + it('returns restore failure code when pull and stash restore both fail', async () => { + sinon.stub(stopCommand, 'runStop').resolves(0) + const runStartStub = sinon.stub(startCommand, 'runStart').resolves(0) + sinon.stub(processUtils, 'runCommandWithOutput').resolves({ + code: 0, + stdout: 'Saved working directory and index state WIP on main: abc123', + stderr: '', + }) + const runCommandStub = sinon + .stub(processUtils, 'runCommand') + .onFirstCall() + .resolves(1) + .onSecondCall() + .resolves(2) + + const code = await runUpdate([]) + + expect(code).to.equal(2) + expect(runCommandStub.firstCall.args).to.deep.equal(['git', ['pull']]) + expect(runCommandStub.secondCall.args).to.deep.equal(['git', ['stash', 'pop']]) + expect(runStartStub.called).to.equal(false) + }) +}) diff --git a/test/unit/seeds/0000-events.spec.ts b/test/unit/seeds/0000-events.spec.ts new file mode 100644 index 00000000..f9f77c8c --- /dev/null +++ b/test/unit/seeds/0000-events.spec.ts @@ -0,0 +1,92 @@ +import { expect } from 'chai' + +import { isEventIdValid, isEventSignatureValid } from '../../../src/utils/event' + +const seedScript = require('../../../seeds/0000-events') +const sourceEvents = require('../../../seeds/events.json') + +type EventRow = { + event_id: Buffer + event_pubkey: Buffer + event_kind: number + event_created_at: number + event_content: string + event_tags: string + event_signature: Buffer +} + +const runSeed = async (requestedCount?: number): Promise => { + if (typeof requestedCount === 'number') { + process.env.NOSTREAM_SEED_COUNT = String(requestedCount) + } else { + delete process.env.NOSTREAM_SEED_COUNT + } + + let rows: EventRow[] = [] + + const knex = ((table: string) => { + if (table !== 'events') { + throw new Error(`Unexpected table: ${table}`) + } + + return { + del: async () => undefined, + } + }) as any + + knex.batchInsert = async (_table: string, insertedRows: EventRow[]) => { + rows = insertedRows + } + + await seedScript.seed(knex) + + return rows +} + +describe('seeds/0000-events', () => { + const originalSeedCount = process.env.NOSTREAM_SEED_COUNT + + afterEach(() => { + if (originalSeedCount === undefined) { + delete process.env.NOSTREAM_SEED_COUNT + return + } + + process.env.NOSTREAM_SEED_COUNT = originalSeedCount + }) + + it('keeps default seed behavior when NOSTREAM_SEED_COUNT is not set', async () => { + const rows = await runSeed() + + expect(rows.length).to.equal(sourceEvents.length) + expect(rows[0].event_id.toString('hex')).to.equal(sourceEvents[0].id) + expect(rows[0].event_pubkey.toString('hex')).to.equal(sourceEvents[0].pubkey) + expect(rows[0].event_signature.toString('hex')).to.equal(sourceEvents[0].sig) + }) + + it('generates deterministic valid events when NOSTREAM_SEED_COUNT is set', async () => { + const firstRunRows = await runSeed(5) + const secondRunRows = await runSeed(5) + + expect(firstRunRows.length).to.equal(5) + expect(secondRunRows.length).to.equal(5) + expect(firstRunRows.map((row) => row.event_id.toString('hex'))).to.deep.equal( + secondRunRows.map((row) => row.event_id.toString('hex')), + ) + + for (const row of firstRunRows) { + const event = { + id: row.event_id.toString('hex'), + pubkey: row.event_pubkey.toString('hex'), + created_at: row.event_created_at, + kind: row.event_kind, + tags: JSON.parse(row.event_tags), + content: row.event_content, + sig: row.event_signature.toString('hex'), + } + + expect(await isEventIdValid(event)).to.equal(true) + expect(await isEventSignatureValid(event)).to.equal(true) + } + }) +}) From 0d168abad405615e5b793a11356e2e5517c117c7 Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Mon, 20 Apr 2026 02:02:37 +0200 Subject: [PATCH 04/20] docs(ci): update docs and checks for CLI workflow --- .env.example | 2 +- .github/workflows/checks.yml | 6 ++ CLI.md | 127 +++++++++++++++++++++++++++++++++++ CONFIGURATION.md | 3 +- README.md | 116 ++++++++++++++++++++------------ 5 files changed, 207 insertions(+), 47 deletions(-) create mode 100644 CLI.md diff --git a/.env.example b/.env.example index 7f335c89..49dfb185 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,6 @@ WORKER_COUNT=2 # Defaults to CPU count. Use 1 or 2 for local testing. # HIDDEN_SERVICE_PORT=80 # --- I2P (Optional) --- -# To enable I2P, use: ./scripts/start_with_i2p +# To enable I2P, use: nostream start --i2p # I2P tunnel configuration lives in i2p/tunnels.conf and i2p/i2pd.conf. # No application-level env vars are needed; the i2pd sidecar handles everything. diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 9c2c2f68..eb550aa8 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -58,6 +58,10 @@ jobs: run: npm ci - name: Run build check run: npm run build:check + - name: Build artifacts + run: npm run build + - name: Verify built CLI entrypoint + run: npm run verify:cli:build test-units-and-cover: name: Unit Tests And Coverage runs-on: ubuntu-latest @@ -76,6 +80,8 @@ jobs: run: npm ci - name: Run unit tests run: npm run test:unit + - name: Run CLI test suite + run: npm run test:cli - name: Run coverage for unit tests run: npm run cover:unit if: ${{ always() }} diff --git a/CLI.md b/CLI.md new file mode 100644 index 00000000..9fe348ea --- /dev/null +++ b/CLI.md @@ -0,0 +1,127 @@ +# Nostream CLI + +Nostream ships a unified command-line interface: + +```bash +nostream --help +npm run cli -- --help +``` + +When run with no arguments in an interactive terminal, `nostream` launches an interactive TUI. +In non-interactive environments, it prints help and exits successfully. + +## Exit Codes + +- `0`: success +- `1`: runtime/validation error +- `2`: usage error (invalid command/options) + +## Core Commands + +```bash +nostream start [--tor] [--i2p] [--nginx] [--debug] [--port 8008] +nostream stop [--all|--tor|--i2p|--nginx|--local] +nostream info [--tor-hostname] [--i2p-hostname] +nostream update +nostream clean +nostream setup [--yes] [--start] +nostream seed [--count 100] +nostream import [file.jsonl|file.json] [--file file.jsonl|file.json] [--batch-size 1000] +nostream export [output] [--output output] [--format jsonl|json] +``` + +## Legacy Script Replacements + +```bash +scripts/start -> nostream start +scripts/start_with_tor -> nostream start --tor +scripts/start_with_i2p -> nostream start --i2p +scripts/start_with_nginx -> nostream start --nginx +scripts/stop -> nostream stop +scripts/print_tor_hostname -> nostream info --tor-hostname +scripts/print_i2p_hostname -> nostream info --i2p-hostname +scripts/update -> nostream update +scripts/clean -> nostream clean +``` + +## Configuration Commands + +```bash +nostream config list +nostream config get +nostream config set [--type inferred|json] [--validate|--no-validate] [--restart] +nostream config validate + +nostream config env list [--show-secrets] +nostream config env get [--show-secrets] +nostream config env set +nostream config env validate +``` + +Path syntax supports dot keys and array indexes: + +```bash +nostream config get limits.event.content[0].maxLength +nostream config set limits.event.content[0].maxLength 2048 +nostream config set nip05.domainWhitelist '["example.com","relay.io"]' --type json +``` + +## Development Commands + +```bash +nostream dev db:clean [--all|--older-than=30|--kinds=1,7,4] [--dry-run] [--force] +nostream dev db:reset [--yes] +nostream dev seed:relay +nostream dev docker:clean [--yes] +nostream dev test:unit +nostream dev test:cli +nostream dev test:integration +``` + +## TUI Navigation + +Run: + +```bash +nostream +``` + +Main menu includes: +- Start relay +- Stop relay +- Configure settings +- Manage data (export/import) +- Development tools +- View relay info +- Exit + +TUI behavior highlights: +- Each submenu includes an explicit `Back` option, so you can return without using signal keys. +- Start menu prompts for Tor/I2P/Debug, optional custom port, and final confirmation. +- Configure menu reads categories from the active settings schema. +- Manage menu asks for import/export format and file paths. +- Dev menu displays explicit destructive warnings before DB reset/clean and Docker clean. + +## Common Workflows + +```bash +# Start relay with Tor + I2P +nostream start --tor --i2p + +# Print Tor hostname +nostream info --tor-hostname + +# Import and export events +nostream import --file ./events.jsonl --batch-size 500 +nostream import --file ./events.json --batch-size 500 +nostream export --output backup.jsonl --format jsonl +nostream export --output backup.json --format json + +# Update YAML settings and restart relay +nostream config set payments.enabled true --restart + +# Update env settings +nostream config env set RELAY_PORT 8008 +nostream config env get SECRET --show-secrets +nostream config env validate +``` diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 624b8cf4..44b92756 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -73,8 +73,7 @@ Tunnel keys are persisted at `.nostr/i2p/data/` so the `.b32.i2p` address surviv The i2pd web console (tunnel status, `.b32.i2p` destinations) is published to the host on **`127.0.0.1:7070`** only. Remove the `ports:` mapping in `docker-compose.i2p.yml` to disable host-side access. -- Start with I2P: `./scripts/start_with_i2p` -- Print hostname hints: `./scripts/print_i2p_hostname` +- Start with I2P: `nostream start --i2p` If you've set READ_REPLICAS to 4, you should configure RR0_ through RR3_. diff --git a/README.md b/README.md index 071a71b8..dd3fedaa 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal - On `.nostr/settings.yaml` file make the following changes: - `payments.processor` to `zebedee` - `paymentsProcessors.zebedee.callbackBaseURL` to match your Nostream URL (e.g. `https://{YOUR_DOMAIN_HERE}/callbacks/zebedee`) - - Restart Nostream (`./scripts/stop` followed by `./scripts/start`) + - Restart Nostream (`nostream stop` followed by `nostream start`) - Read the in-depth guide for more information: [Set Up a Paid Nostr Relay with ZEBEDEE API](https://docs.zebedee.io/docs/guides/nostr-relay) 3. [Nodeless](https://nodeless.io/?ref=587f477f-ba1c-4bd3-8986-8302c98f6731) @@ -131,7 +131,7 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal - On your `.nostr/settings.yaml` file make the following changes: - Set `payments.processor` to `nodeless` - Set `paymentsProcessors.nodeless.storeId` to your store ID - - Restart Nostream (`./scripts/stop` followed by `./scripts/start`) + - Restart Nostream (`nostream stop` followed by `nostream start`) 4. [OpenNode](https://www.opennode.com/) - Complete the step "Before you begin" @@ -146,7 +146,7 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal - On your `.nostr/settings.yaml` file make the following changes: - Set `payments.processor` to `opennode` - - Restart Nostream (`./scripts/stop` followed by `./scripts/start`) + - Restart Nostream (`nostream stop` followed by `nostream start`) 5. [LNBITS](https://lnbits.com/) - Complete the step "Before you begin" @@ -163,7 +163,7 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal - Set `payments.processor` to `lnbits` - set `lnbits.baseURL` to your LNbits instance URL (e.g. `https://{YOUR_LNBITS_DOMAIN_HERE}/`) - Set `paymentsProcessors.lnbits.callbackBaseURL` to match your Nostream URL (e.g. `https://{YOUR_DOMAIN_HERE}/callbacks/lnbits`) - - Restart Nostream (`./scripts/stop` followed by `./scripts/start`) + - Restart Nostream (`nostream stop` followed by `nostream start`) 6. [Alby](https://getalby.com/) or any LNURL Provider with [LNURL-verify](https://github.com/lnurl/luds/issues/182) support - Complete the step "Before you begin" @@ -171,7 +171,7 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal - On your `.nostr/settings.yaml` file make the following changes: - Set `payments.processor` to `lnurl` - Set `lnurl.invoiceURL` to your LNURL (e.g. `https://getalby.com/lnurlp/your-username`) - - Restart Nostream (`./scripts/stop` followed by `./scripts/start`) + - Restart Nostream (`nostream stop` followed by `nostream start`) 7. Ensure payments are required for your public key - Visit https://{YOUR-DOMAIN}/ @@ -187,6 +187,18 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal ## Quick Start (Docker Compose) +For full command reference and interactive mode documentation, see [CLI.md](CLI.md). +Non-interactive CLI usage conventions: +- exit `0` on success +- exit `1` on runtime/validation errors +- exit `2` on usage errors (invalid command/options) + +Optional global installation from a source checkout: + ``` + npm i -g . + nostream --help + ``` + Install Docker following the [official guide](https://docs.docker.com/engine/install/). You may have to uninstall Docker if you installed it using a different guide. @@ -206,58 +218,72 @@ Copy the output and paste it into an `.env` file: Start: ``` - ./scripts/start + nostream start ``` or ``` - ./scripts/start_with_tor - ``` - or, with Nginx reverse proxy and Let's Encrypt SSL: - ``` - RELAY_DOMAIN=relay.example.com CERTBOT_EMAIL=you@example.com ./scripts/start_with_nginx + nostream start --tor ``` - -**Windows / WSL2 users:** Docker bind-mounts can cause PostgreSQL permission errors on Windows. Use the dedicated override file instead: + or ``` - docker compose -f docker-compose.yml -f docker-compose.windows.yml up --build + nostream start --i2p ``` - Or add this to your `.env` file so you don't have to type it every time: + or ``` - COMPOSE_FILE=docker-compose.yml:docker-compose.windows.yml + RELAY_DOMAIN=relay.example.com CERTBOT_EMAIL=you@example.com nostream start --nginx ``` - > **Note:** If you previously ran Nostream on Linux/Mac and are switching to Windows, your existing data lives at `.nostr/data/` on the host. You'll need to copy it into the Docker named volume manually or it won't be visible to the new setup. Stop the server with: ``` - ./scripts/stop + nostream stop ``` Print the Tor hostname: ``` - ./scripts/print_tor_hostname + nostream info --tor-hostname ``` -Start with I2P: +Print I2P hostname(s): ``` - ./scripts/start_with_i2p + nostream info --i2p-hostname ``` -Print the I2P hostname: - ``` - ./scripts/print_i2p_hostname - ``` +Legacy script replacements: -### Importing events from JSON Lines +``` +scripts/start -> nostream start +scripts/start_with_tor -> nostream start --tor +scripts/start_with_i2p -> nostream start --i2p +scripts/start_with_nginx -> nostream start --nginx +scripts/stop -> nostream stop +scripts/print_tor_hostname -> nostream info --tor-hostname +scripts/print_i2p_hostname -> nostream info --i2p-hostname +scripts/update -> nostream update +scripts/clean -> nostream clean +``` + +### Importing events from JSON Lines or JSON Arrays -You can import NIP-01 events from `.jsonl` files directly into the relay database. -Compressed files are also supported and decompressed on-the-fly: +You can import NIP-01 events from `.jsonl` (JSON Lines) or `.json` (JSON array) files directly into the relay database. + +Compressed `.jsonl` files are also supported and decompressed on-the-fly: - `.jsonl.gz` (Gzip) - `.jsonl.xz` (XZ) Basic import: ``` - npm run import -- ./events.jsonl + nostream import ./events.jsonl + ``` + +Equivalent alias form: + ``` + nostream import --file ./events.jsonl + ``` + +Import from a JSON array file (compatible with `nostream export --format json`): + ``` + nostream import --file ./events.json ``` Import a compressed backup: @@ -268,12 +294,13 @@ Import a compressed backup: Set a custom batch size (default: `1000`): ``` - npm run import -- ./events.jsonl --batch-size 500 + nostream import ./events.jsonl --batch-size 500 ``` The importer: - Processes the file line-by-line to keep memory usage bounded. +- Streams JSON array items one by one to keep memory usage bounded. - Validates NIP-01 schema, event id hash, and Schnorr signature before insertion. - Inserts in database transactions per batch. - Skips duplicates without failing the whole import. @@ -303,8 +330,8 @@ You can [install as a systemd service](https://www.swissrouting.com/nostr.html#i RestartSec=5 User=nostr WorkingDirectory=/home/nostr/nostream - ExecStart=/home/nostr/nostream/scripts/start - ExecStop=/home/nostr/nostream/scripts/stop + ExecStart=/usr/bin/env bash -lc 'cd /home/nostr/nostream && nostream start' + ExecStop=/usr/bin/env bash -lc 'cd /home/nostr/nostream && nostream stop' [Install] WantedBy=multi-user.target @@ -371,7 +398,7 @@ To fix this, configure Docker daemon DNS in `/etc/docker/daemon.json`. 4. Retry starting nostream: ``` - ./scripts/start + nostream start ``` Note: avoid `127.0.0.53` in Docker DNS settings because it points to the host's @@ -486,7 +513,7 @@ Clone repository and enter directory: Start: ``` - ./scripts/start + nostream start ``` This will run in the foreground of the terminal until you stop it with Ctrl+C. @@ -651,7 +678,7 @@ To observe client and subscription counts in real-time during a test, you can in ``` ## Export Events -Export all stored events to a [JSON Lines](https://jsonlines.org/) (`.jsonl`) file. Each line is a valid NIP-01 Nostr event JSON object. The export streams rows from the database using cursors, so it works safely on relays with millions of events without loading them into memory. +Export all stored events to either [JSON Lines](https://jsonlines.org/) (`.jsonl`) or JSON array (`.json`) format. The export streams rows from the database using cursors, so it works safely on relays with millions of events without loading them into memory. Optional compression is supported for lower storage and transfer costs: @@ -663,6 +690,8 @@ npm run export # writes to events.jsonl npm run export -- backup-2024-01-01.jsonl # custom filename npm run export -- backup.jsonl.gz --compress --format=gzip npm run export -- backup.jsonl.xz --compress --format=xz +nostream export --output backup-2024-01-01.jsonl # alias form +nostream export --output backup-2024-01-01.json --format json # JSON array output ``` Flags: @@ -705,43 +734,42 @@ npm run db:verify-index-impact ``` It seeds ~200k synthetic events, drops the hot-path indexes, runs EXPLAIN (ANALYZE, BUFFERS) for each hot query, recreates the indexes, and prints a BEFORE/AFTER table. See the *Database indexes and benchmarking* section of [CONFIGURATION.md](CONFIGURATION.md). - ## Relay Maintenance -Use `clean-db` to wipe or prune `events` table data. This also removes +Use `nostream dev db:clean` to wipe or prune `events` table data. This also removes corresponding data from the derived `event_tags` table when present. Dry run (no deletion): ``` - npm run clean-db -- --all --dry-run + nostream dev db:clean --all --dry-run ``` Full wipe: ``` - npm run clean-db -- --all --force + nostream dev db:clean --all --force ``` Delete events older than N days: ``` - npm run clean-db -- --older-than=30 --force + nostream dev db:clean --older-than=30 --force ``` Delete only selected kinds: ``` - npm run clean-db -- --kinds=1,7,4 --force + nostream dev db:clean --kinds=1,7,4 --force ``` Delete only selected kinds older than N days: ``` - npm run clean-db -- --older-than=30 --kinds=1,7,4 --force + nostream dev db:clean --older-than=30 --kinds=1,7,4 --force ``` -By default, the script asks for explicit confirmation (`Type 'DELETE' to confirm`). +By default, the command asks for explicit confirmation (`Type 'DELETE' to confirm`). Use `--force` to skip the prompt. @@ -749,7 +777,7 @@ Use `--force` to skip the prompt. You can change the default folder by setting the `NOSTR_CONFIG_DIR` environment variable to a different path. -Run nostream using one of the quick-start guides at least once and `nostream/.nostr/settings.json` will be created. +Run nostream using one of the quick-start guides at least once and `nostream/.nostr/settings.yaml` will be created. Any changes made to the settings file will be read on the next start. Default settings can be found under `resources/default-settings.yaml`. Feel free to copy it to `nostream/.nostr/settings.yaml` if you would like to have a settings file before running the relay first. From 86b5a00b067585195c7df4427ad707fbaa8ddaa8 Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Mon, 20 Apr 2026 02:08:52 +0200 Subject: [PATCH 05/20] chore: sync package-lock with package.json --- package-lock.json | 1144 ++++++++++++++++++++++----------------------- 1 file changed, 570 insertions(+), 574 deletions(-) diff --git a/package-lock.json b/package-lock.json index 39ef3f0e..0f5ceaec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -396,6 +396,74 @@ "@biomejs/cli-win32-x64": "2.4.12" } }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.12.tgz", + "integrity": "sha512-BnMU4Pc3ciEVteVpZ0BK33MLr7X57F5w1dwDLDn+/iy/yTrA4Q/N2yftidFtsA4vrDh0FMXDpacNV/Tl3fbmng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.12.tgz", + "integrity": "sha512-x9uJ0bI1rJsWICp3VH8w/5PnAVD3A7SqzDpbrfoUQX1QyWrK5jSU4fRLo/wSgGeplCivbxBRKmt5Xq4/nWvq8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.12.tgz", + "integrity": "sha512-tOwuCuZZtKi1jVzbk/5nXmIsziOB6yqN8c9r9QM0EJYPU6DpQWf11uBOSCfFKKM4H3d9ZoarvlgMfbcuD051Pw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.12.tgz", + "integrity": "sha512-FhfpkAAlKL6kwvcVap0Hgp4AhZmtd3YImg0kK1jd7C/aSoh4SfsB2f++yG1rU0lr8Y5MCFJrcSkmssiL9Xnnig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@biomejs/cli-linux-x64": { "version": "2.4.12", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.12.tgz", @@ -430,6 +498,40 @@ "node": ">=14.21.3" } }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.12.tgz", + "integrity": "sha512-B0DLnx0vA9ya/3v7XyCaP+/lCpnbWbMOfUFFve+xb5OxyYvdHaS55YsSddr228Y+JAFk58agCuZTsqNiw2a6ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.12.tgz", + "integrity": "sha512-yMckRzTyZ83hkk8iDFWswqSdU8tvZxspJKnYNh7JZr/zhZNOlzH13k4ecboU6MurKExCe2HUkH75pGI/O2JwGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@changesets/apply-release-plan": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.1.1.tgz", @@ -452,41 +554,6 @@ "semver": "^7.5.3" } }, - "node_modules/@changesets/apply-release-plan/node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@changesets/apply-release-plan/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@changesets/apply-release-plan/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/@changesets/assemble-release-plan": { "version": "6.0.10", "resolved": "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-6.0.10.tgz", @@ -550,41 +617,6 @@ "changeset": "bin.js" } }, - "node_modules/@changesets/cli/node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@changesets/cli/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@changesets/cli/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/@changesets/config": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@changesets/config/-/config-3.1.4.tgz", @@ -602,41 +634,6 @@ "micromatch": "^4.0.8" } }, - "node_modules/@changesets/config/node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@changesets/config/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@changesets/config/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/@changesets/errors": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@changesets/errors/-/errors-0.2.0.tgz", @@ -730,41 +727,6 @@ "fs-extra": "^7.0.1" } }, - "node_modules/@changesets/pre/node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@changesets/pre/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@changesets/pre/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/@changesets/read": { "version": "0.6.7", "resolved": "https://registry.npmjs.org/@changesets/read/-/read-0.6.7.tgz", @@ -781,41 +743,6 @@ "picocolors": "^1.1.0" } }, - "node_modules/@changesets/read/node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@changesets/read/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@changesets/read/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/@changesets/should-skip-package": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@changesets/should-skip-package/-/should-skip-package-0.1.2.tgz", @@ -847,41 +774,6 @@ "prettier": "^2.7.1" } }, - "node_modules/@changesets/write/node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@changesets/write/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@changesets/write/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/@clack/core": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz", @@ -1023,6 +915,35 @@ "node": ">=v14" } }, + "node_modules/@commitlint/is-ignored/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@commitlint/is-ignored/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@commitlint/lint": { "version": "17.8.1", "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-17.8.1.tgz", @@ -1114,6 +1035,44 @@ "node": ">=v14" } }, + "node_modules/@commitlint/read/node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@commitlint/read/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@commitlint/read/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/@commitlint/resolve-extends": { "version": "17.8.1", "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-17.8.1.tgz", @@ -1507,9 +1466,9 @@ } }, "node_modules/@cucumber/messages": { - "version": "32.2.0", - "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-32.2.0.tgz", - "integrity": "sha512-oYp1dgL2TByYWL51Z+rNm+/mFtJhiPU9WS03goes9EALb8d9GFcXRbG1JluFLFaChF1YDqIzLac0kkC3tv1DjQ==", + "version": "32.3.1", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-32.3.1.tgz", + "integrity": "sha512-yNQq1KoXRYaEKrWMFmpUQX7TdeQuU9jeGgJAZ3dArTsC/T4NpJ6DnqaJIIgwPnz/wtQIQTNX7/h0rOuF5xY4qQ==", "dev": true, "license": "MIT", "peer": true, @@ -1594,30 +1553,6 @@ } } }, - "node_modules/@inquirer/external-editor/node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1649,19 +1584,6 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@isaacs/cliui/node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -1703,24 +1625,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "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", @@ -1819,9 +1723,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -1927,16 +1831,6 @@ "node": ">=6 <7 || >=8" } }, - "node_modules/@manypkg/find-root/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/@manypkg/find-root/node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -1979,16 +1873,6 @@ "node": ">=8" } }, - "node_modules/@manypkg/find-root/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/@manypkg/get-packages": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@manypkg/get-packages/-/get-packages-1.1.3.tgz", @@ -2026,26 +1910,6 @@ "node": ">=6 <7 || >=8" } }, - "node_modules/@manypkg/get-packages/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@manypkg/get-packages/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/@noble/secp256k1": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.1.tgz", @@ -2197,24 +2061,6 @@ "url": "https://opencollective.com/pnpm" } }, - "node_modules/@pnpm/fetching-types/node_modules/node-fetch": { - "version": "3.0.0-beta.9", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.0.0-beta.9.tgz", - "integrity": "sha512-RdbZCEynH2tH46+tj0ua9caUHVWrd/RHnRfvly2EVdqGmI3ndS1Vn/xjm5KuGejDt2RNDQsVRLPNd2QPwcewVg==", - "dev": true, - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^3.0.1", - "fetch-blob": "^2.1.1" - }, - "engines": { - "node": "^10.17 || >=12.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/@pnpm/graceful-fs": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@pnpm/graceful-fs/-/graceful-fs-3.2.0.tgz", @@ -3070,9 +2916,9 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", + "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -3118,9 +2964,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.17", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", - "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", + "version": "2.10.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", + "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3189,6 +3035,18 @@ "ms": "2.0.0" } }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -3207,9 +3065,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -3435,9 +3293,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001787", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", - "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", "dev": true, "funding": [ { @@ -3545,6 +3403,13 @@ "node": ">=8" } }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -3559,44 +3424,19 @@ } }, "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" + "node": ">= 14.16.0" }, - "engines": { - "node": ">= 6" + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/class-transformer": { @@ -3674,6 +3514,40 @@ "node": ">=12" } }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/clone": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", @@ -3862,9 +3736,9 @@ } }, "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, "node_modules/cosmiconfig": { @@ -4204,9 +4078,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.334", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", - "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + "version": "1.5.340", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", "dev": true, "license": "ISC" }, @@ -4351,16 +4225,13 @@ "license": "MIT" }, "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.8.0" } }, "node_modules/esm": { @@ -4536,19 +4407,6 @@ "node": ">=8.6.0" } }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-redact": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", @@ -4647,16 +4505,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4864,18 +4712,18 @@ "license": "MIT" }, "node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" }, "engines": { - "node": ">=14.14" + "node": ">=6 <7 || >=8" } }, "node_modules/fs.realpath": { @@ -4885,6 +4733,21 @@ "dev": true, "license": "ISC" }, + "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/fsu": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/fsu/-/fsu-1.1.1.tgz", @@ -5060,6 +4923,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/global-dirs": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", @@ -5211,9 +5087,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "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" @@ -5329,15 +5205,20 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/identity-function": { @@ -5965,14 +5846,11 @@ } }, "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -6152,19 +6030,6 @@ "node": ">=8.6.0" } }, - "node_modules/knip/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/knip/node_modules/globby": { "version": "13.2.2", "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", @@ -6198,17 +6063,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/knip/node_modules/strip-json-comments": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", - "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "node_modules/knip/node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=14.16" - }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/knuth-shuffle-seeded": { @@ -6664,6 +6526,18 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -6821,22 +6695,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/mocha/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/mocha/node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -6865,6 +6723,19 @@ "node": ">=0.3.1" } }, + "node_modules/mocha/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mocha/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6872,18 +6743,17 @@ "dev": true, "license": "MIT" }, - "node_modules/mocha/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "node_modules/mocha/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">=8" }, "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/mocha/node_modules/yargs-parser": { @@ -6953,7 +6823,30 @@ "universalify": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=12" + } + }, + "node_modules/mochawesome-report-generator/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/mochawesome-report-generator/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" } }, "node_modules/mochawesome/node_modules/diff": { @@ -7092,6 +6985,24 @@ "license": "MIT", "optional": true }, + "node_modules/node-fetch": { + "version": "3.0.0-beta.9", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.0.0-beta.9.tgz", + "integrity": "sha512-RdbZCEynH2tH46+tj0ua9caUHVWrd/RHnRfvly2EVdqGmI3ndS1Vn/xjm5KuGejDt2RNDQsVRLPNd2QPwcewVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^3.0.1", + "fetch-blob": "^2.1.1" + }, + "engines": { + "node": "^10.17 || >=12.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -7232,9 +7143,9 @@ } }, "node_modules/nyc/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -8126,6 +8037,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pino": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/pino/-/pino-8.21.0.tgz", @@ -8238,19 +8159,6 @@ "node": ">= 10.x" } }, - "node_modules/pino-pretty/node_modules/strip-json-comments": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", - "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/pino-std-serializers": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.2.2.tgz", @@ -8612,6 +8520,18 @@ "node": ">= 0.8" } }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -8823,16 +8743,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/read-yaml-file/node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/read-yaml-file/node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -8859,16 +8769,17 @@ } }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, "engines": { - "node": ">=8.10.0" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/real-require": { @@ -8993,6 +8904,29 @@ "node": ">=12" } }, + "node_modules/rename-overwrite/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/rename-overwrite/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", @@ -9031,11 +8965,12 @@ "license": "ISC" }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -9184,9 +9119,9 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -9323,14 +9258,11 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -9338,19 +9270,6 @@ "node": ">=10" } }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -9390,18 +9309,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/send/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/send/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9963,13 +9870,13 @@ } }, "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=14.16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10065,9 +9972,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -10371,9 +10278,9 @@ } }, "node_modules/ts-node-dev/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -10381,6 +10288,31 @@ "concat-map": "0.0.1" } }, + "node_modules/ts-node-dev/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/ts-node-dev/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -10429,6 +10361,19 @@ "node": ">=10" } }, + "node_modules/ts-node-dev/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/ts-node-dev/node_modules/rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", @@ -10608,13 +10553,13 @@ } }, "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 10.0.0" + "node": ">= 4.0.0" } }, "node_modules/unpipe": { @@ -10802,18 +10747,18 @@ "license": "Apache-2.0" }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -10854,20 +10799,71 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/wrappy": { @@ -11115,9 +11111,9 @@ } }, "node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" From 7db20be149b95804d45ce363bbdadd2fefc3d354 Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Mon, 20 Apr 2026 02:19:35 +0200 Subject: [PATCH 06/20] feat: add CLI management commands and config tooling --- .changeset/sour-dolls-sip.md | 5 +++++ .knip.json | 7 +++++-- package-lock.json | 1 - package.json | 1 - 4 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 .changeset/sour-dolls-sip.md diff --git a/.changeset/sour-dolls-sip.md b/.changeset/sour-dolls-sip.md new file mode 100644 index 00000000..b33932ed --- /dev/null +++ b/.changeset/sour-dolls-sip.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +Fix CI stability by including the CLI entrypoint in Knip analysis and removing an unused dependency. diff --git a/.knip.json b/.knip.json index 193d47bc..902d72aa 100644 --- a/.knip.json +++ b/.knip.json @@ -3,6 +3,7 @@ "entry": [ "src/index.ts", "src/import-events.ts", + "src/cli/index.ts", "knexfile.js" ], "project": [ @@ -11,7 +12,9 @@ "ignoreDependencies": [ "lzma-native" ], - "ignoreFiles": [], + "ignore": [ + ".nostr/**" + ], "commitlint": false, "eslint": false, "github-actions": false, @@ -19,4 +22,4 @@ "mocha": false, "nyc": false, "semantic-release": false -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index 0f5ceaec..2a0255cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "axios": "^1.15.0", "cac": "^7.0.0", "colorette": "^2.0.20", - "debug": "4.3.4", "express": "4.22.1", "js-yaml": "4.1.1", "knex": "2.4.2", diff --git a/package.json b/package.json index cbe94bd4..8924e7f4 100644 --- a/package.json +++ b/package.json @@ -142,7 +142,6 @@ "axios": "^1.15.0", "cac": "^7.0.0", "colorette": "^2.0.20", - "debug": "4.3.4", "express": "4.22.1", "js-yaml": "4.1.1", "knex": "2.4.2", From 6793145daa32e6b56d6692855e902a70c5ba4a5a Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Mon, 20 Apr 2026 03:20:40 +0200 Subject: [PATCH 07/20] feat(cli): update TUI to include stop menu and remove unused formatting and validation utilities --- .knip.json | 11 ++++++----- src/cli/tui/main.ts | 4 ++-- src/cli/utils/formatting.ts | 3 --- src/cli/utils/validation.ts | 11 ----------- 4 files changed, 8 insertions(+), 21 deletions(-) delete mode 100644 src/cli/utils/formatting.ts delete mode 100644 src/cli/utils/validation.ts diff --git a/.knip.json b/.knip.json index 902d72aa..eb3256db 100644 --- a/.knip.json +++ b/.knip.json @@ -1,13 +1,14 @@ { "$schema": "https://unpkg.com/knip@2/schema.json", "entry": [ - "src/index.ts", - "src/import-events.ts", - "src/cli/index.ts", - "knexfile.js" + "src/index.ts!", + "src/import-events.ts!", + "src/cli/index.ts!", + "src/scripts/benchmark-queries.ts!", + "knexfile.js!" ], "project": [ - "src/**/*.ts" + "src/**/*.ts!" ], "ignoreDependencies": [ "lzma-native" diff --git a/src/cli/tui/main.ts b/src/cli/tui/main.ts index 990ec276..928e9d98 100644 --- a/src/cli/tui/main.ts +++ b/src/cli/tui/main.ts @@ -1,9 +1,9 @@ import { runInfo } from '../commands/info' import { runStartMenu } from './menus/start' +import { runStopMenu } from './menus/stop' import { runConfigureMenu } from './menus/configure' import { runManageMenu } from './menus/manage' import { runDevMenu } from './menus/dev' -import { runStop } from '../commands/stop' import { createState } from './state' import { tuiPrompts } from './prompts' @@ -35,7 +35,7 @@ export const runTui = async (): Promise => { await runStartMenu() break case 'stop': - await runStop({ all: true }, []) + await runStopMenu() break case 'configure': await runConfigureMenu() diff --git a/src/cli/utils/formatting.ts b/src/cli/utils/formatting.ts deleted file mode 100644 index d54fe852..00000000 --- a/src/cli/utils/formatting.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const formatJson = (value: unknown): string => JSON.stringify(value, null, 2) - -export const formatKeyValue = (key: string, value: string): string => `${key}: ${value}` diff --git a/src/cli/utils/validation.ts b/src/cli/utils/validation.ts deleted file mode 100644 index b87e3fa0..00000000 --- a/src/cli/utils/validation.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const requireTruthy = (value: unknown, message: string): void => { - if (!value) { - throw new Error(message) - } -} - -export const requirePositiveInteger = (value: number, label: string): void => { - if (!Number.isSafeInteger(value) || value <= 0) { - throw new Error(`${label} must be a positive integer`) - } -} From c19adc93bd21eda59c56054ae0c159c141d751c7 Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Mon, 20 Apr 2026 03:54:45 +0200 Subject: [PATCH 08/20] feat(tests): ensure test reports directory is created after build in pretest:unit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8924e7f4..69cf0698 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "db:seed": "knex seed:run", "db:benchmark": "node --env-file-if-exists=.env -r ts-node/register src/scripts/benchmark-queries.ts", "db:verify-index-impact": "node --env-file-if-exists=.env -r ts-node/register scripts/verify-index-impact.ts", - "pretest:unit": "node -e \"require('fs').mkdirSync('.test-reports/unit', {recursive: true})\"", + "pretest:unit": "npm run build && node -e \"require('fs').mkdirSync('.test-reports/unit', {recursive: true})\"", "test:unit": "mocha 'test/**/*.spec.ts'", "pretest:cli": "npm run build", "test:cli": "mocha 'test/unit/cli/**/*.spec.ts'", From 3a6ee40a407972f3c02e121a4062bfa5a47b8a6d Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Sat, 25 Apr 2026 06:49:36 +0300 Subject: [PATCH 09/20] feat(cli): add JSON output support for info and config commands; remove legacy wrapper scripts --- CLI.md | 17 +- README.md | 3 +- src/cli/commands/config.ts | 34 ++- src/cli/commands/info.ts | 54 ++++- src/cli/index.ts | 20 +- src/cli/tui/menus/configure.ts | 305 +++++++++++++++++++++++++- src/cli/utils/process.ts | 27 ++- test/unit/cli/cli.integration.spec.ts | 77 +++++++ test/unit/cli/docs.spec.ts | 41 ++++ test/unit/cli/tui.spec.ts | 85 +++++++ 10 files changed, 646 insertions(+), 17 deletions(-) create mode 100644 test/unit/cli/docs.spec.ts diff --git a/CLI.md b/CLI.md index 9fe348ea..8b9d0c09 100644 --- a/CLI.md +++ b/CLI.md @@ -21,7 +21,7 @@ In non-interactive environments, it prints help and exits successfully. ```bash nostream start [--tor] [--i2p] [--nginx] [--debug] [--port 8008] nostream stop [--all|--tor|--i2p|--nginx|--local] -nostream info [--tor-hostname] [--i2p-hostname] +nostream info [--tor-hostname] [--i2p-hostname] [--json] nostream update nostream clean nostream setup [--yes] [--start] @@ -30,7 +30,10 @@ nostream import [file.jsonl|file.json] [--file file.jsonl|file.json] [--batch-si nostream export [output] [--output output] [--format jsonl|json] ``` -## Legacy Script Replacements +## Removed Legacy Wrappers + +The old shell wrapper scripts are no longer shipped in `scripts/`. +Use the unified `nostream` CLI directly instead: ```bash scripts/start -> nostream start @@ -48,7 +51,9 @@ scripts/clean -> nostream clean ```bash nostream config list +nostream config list --json nostream config get +nostream config get --json nostream config set [--type inferred|json] [--validate|--no-validate] [--restart] nostream config validate @@ -98,7 +103,8 @@ Main menu includes: TUI behavior highlights: - Each submenu includes an explicit `Back` option, so you can return without using signal keys. - Start menu prompts for Tor/I2P/Debug, optional custom port, and final confirmation. -- Configure menu reads categories from the active settings schema. +- Configure menu offers guided editing for common categories such as payments, network, and limits. +- Advanced dot-path get/set remains available for full settings access. - Manage menu asks for import/export format and file paths. - Dev menu displays explicit destructive warnings before DB reset/clean and Docker clean. @@ -111,6 +117,11 @@ nostream start --tor --i2p # Print Tor hostname nostream info --tor-hostname +# Machine-readable output for automation +nostream info --json +nostream config list --json +nostream config get payments.enabled --json + # Import and export events nostream import --file ./events.jsonl --batch-size 500 nostream import --file ./events.json --batch-size 500 diff --git a/README.md b/README.md index 54a821a9..e92e3a3e 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,8 @@ Print I2P hostname(s): nostream info --i2p-hostname ``` -Legacy script replacements: +The old shell wrapper scripts are no longer shipped in `scripts/`. +Use the unified `nostream` CLI directly instead: ``` scripts/start -> nostream start diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index cf7214de..0a7812fd 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -27,6 +27,20 @@ import { runStop } from './stop' type ValueType = 'inferred' | 'json' +const toJson = (value: unknown): string => { + return JSON.stringify( + value, + (_key, entry) => { + if (typeof entry === 'bigint') { + return entry.toString() + } + + return entry + }, + 2, + ) +} + const serialize = (value: unknown): string => { if (typeof value === 'bigint') { return value.toString() @@ -70,21 +84,37 @@ const restartRelay = async (): Promise => { return 0 } -export const runConfigList = async (): Promise => { +export const runConfigList = async (options: { json?: boolean } = {}): Promise => { const settings = loadMergedSettings() + + if (options.json) { + logInfo(toJson(settings)) + return 0 + } + logInfo(yaml.dump(settings, { lineWidth: 120 })) return 0 } -export const runConfigGet = async (path: string): Promise => { +export const runConfigGet = async (path: string, options: { json?: boolean } = {}): Promise => { const settings = loadMergedSettings() as unknown as Record const value = getByPath(settings, path) if (value === undefined) { + if (options.json) { + process.stderr.write(`${JSON.stringify({ error: { message: `Path not found: ${path}`, code: 1 } })}\n`) + return 1 + } + logError(`Path not found: ${path}`) return 1 } + if (options.json) { + logInfo(toJson(value)) + return 0 + } + logInfo(serialize(value)) return 0 } diff --git a/src/cli/commands/info.ts b/src/cli/commands/info.ts index bae2a6d7..85cbb154 100644 --- a/src/cli/commands/info.ts +++ b/src/cli/commands/info.ts @@ -1,7 +1,7 @@ import fs from 'fs' +import knex from 'knex' import packageJson from '../../../package.json' -import { getMasterDbClient } from '../../database/client' import { loadMergedSettings } from '../utils/config' import { logError, logInfo } from '../utils/output' import { getOnionKeyPath, getTorHostnamePath } from '../utils/bootstrap' @@ -11,10 +11,31 @@ import { runCommandWithOutput } from '../utils/process' type InfoOptions = { torHostname?: boolean i2pHostname?: boolean + json?: boolean } const getEventCount = async (): Promise => { - const db = getMasterDbClient() + const db = knex({ + client: 'pg', + connection: process.env.DB_URI + ? process.env.DB_URI + : { + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT), + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + }, + pool: { + min: 0, + max: 1, + idleTimeoutMillis: 1000, + acquireTimeoutMillis: 1000, + propagateCreateError: false, + }, + acquireConnectionTimeout: 1000, + } as any) + try { const result = await db('events').whereNull('deleted_at').count<{ count: string | number }>('* as count').first() return Number(result?.count ?? 0) @@ -26,7 +47,7 @@ const getEventCount = async (): Promise => { } const getRelayUptimeSeconds = async (): Promise => { - const idResult = await runCommandWithOutput('docker', ['compose', 'ps', '-q', 'nostream']) + const idResult = await runCommandWithOutput('docker', ['compose', 'ps', '-q', 'nostream'], { timeoutMs: 1000 }) if (idResult.code !== 0) { return null } @@ -36,7 +57,9 @@ const getRelayUptimeSeconds = async (): Promise => { return null } - const startedAtResult = await runCommandWithOutput('docker', ['inspect', '--format', '{{.State.StartedAt}}', containerId]) + const startedAtResult = await runCommandWithOutput('docker', ['inspect', '--format', '{{.State.StartedAt}}', containerId], { + timeoutMs: 1000, + }) if (startedAtResult.code !== 0) { return null } @@ -75,7 +98,11 @@ const formatUptime = (uptimeSeconds: number | null): string => { return segments.join(' ') } -const getInfoPayload = async () => { +const writeJson = (value: unknown): void => { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`) +} + +export const getInfoPayload = async () => { const settings = loadMergedSettings() const torHostnamePath = getTorHostnamePath() const torHostname = fs.existsSync(torHostnamePath) ? fs.readFileSync(torHostnamePath, 'utf-8').trim() : null @@ -106,10 +133,22 @@ export const runInfo = async (options: InfoOptions): Promise => { if (options.torHostname) { if (payload.tor.hostname) { + if (options.json) { + writeJson({ torHostname: payload.tor.hostname }) + return 0 + } + logInfo(payload.tor.hostname) return 0 } + if (options.json) { + process.stderr.write( + `${JSON.stringify({ error: { message: 'Tor hostname not found. Start with `nostream start --tor` first.', code: 1 } })}\n`, + ) + return 1 + } + logError('Tor hostname not found. Start with `nostream start --tor` first.') return 1 } @@ -150,6 +189,11 @@ export const runInfo = async (options: InfoOptions): Promise => { return 0 } + if (options.json) { + writeJson(payload) + return 0 + } + logInfo(`Nostream v${payload.version}`) logInfo(`Relay: ${payload.relay.name ?? 'n/a'} (${payload.relay.url ?? 'n/a'})`) logInfo(`Pubkey: ${payload.relay.pubkey ?? 'n/a'}`) diff --git a/src/cli/index.ts b/src/cli/index.ts index 62a570d7..3559a08b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -109,7 +109,18 @@ const withErrorBoundary = } catch (error) { const message = error instanceof Error ? error.message : String(error) const usageError = error instanceof CliUsageError - printHandledError(message) + const lastArg = args[args.length - 1] + const jsonMode = + Boolean(lastArg) && + typeof lastArg === 'object' && + !Array.isArray(lastArg) && + (lastArg as Record).json === true + + if (jsonMode) { + process.stderr.write(`${JSON.stringify({ error: { message, code: usageError ? 2 : 1 } })}\n`) + } else { + printHandledError(message) + } process.exitCode = usageError ? 2 : 1 } } @@ -147,6 +158,7 @@ cli .command('info', 'Show relay/runtime info') .option('--tor-hostname', 'Print Tor hostname only') .option('--i2p-hostname', 'Print I2P hostname(s) when available') + .option('--json', 'Print machine-readable JSON') .action( withErrorBoundary(async (options: unknown) => { return runInfo(options as any) @@ -286,11 +298,13 @@ cli .option('--no-validate', 'Skip validation before write') .option('--type ', 'Value parser: inferred|json') .option('--show-secrets', 'Show secret values for env commands') + .option('--json', 'Print machine-readable JSON for read commands') .action( withErrorBoundary(async (args: unknown, options: unknown) => { const positional = (args as string[]) ?? [] const command = positional[0] const resolved = options as Record + const json = Boolean(resolved.json) if (resolved.help && command === 'env') { const envCommand = positional[1] @@ -335,12 +349,12 @@ cli switch (command) { case 'list': - return runConfigList() + return runConfigList({ json }) case 'get': if (!positional[1]) { throw new CliUsageError(configSubHelp.get) } - return runConfigGet(positional[1]) + return runConfigGet(positional[1], { json }) case 'set': { if (!positional[1] || positional[2] === undefined) { throw new CliUsageError(configSubHelp.set) diff --git a/src/cli/tui/menus/configure.ts b/src/cli/tui/menus/configure.ts index 8d51d584..8bd13703 100644 --- a/src/cli/tui/menus/configure.ts +++ b/src/cli/tui/menus/configure.ts @@ -5,6 +5,7 @@ import { runConfigSet, runConfigValidate, } from '../../commands/config' +import { getByPath, loadMergedSettings } from '../../utils/config' import { tuiPrompts } from '../prompts' const toCategoryLabel = (key: string): string => { @@ -27,13 +28,309 @@ const getCategoryOptions = () => { ] } +type GuidedSetting = { + label: string + path: string + type: 'boolean' | 'number' | 'string' | 'select' | 'stringArray' + options?: string[] + placeholder?: string + validate?: (value: string) => string | undefined +} + +type GuidedCategory = { + value: string + label: string + settings: GuidedSetting[] +} + +const requireNonEmpty = (value: string): string | undefined => { + return value.trim() ? undefined : 'Value is required' +} + +const requireSafeNonNegativeInteger = (value: string): string | undefined => { + const trimmed = value.trim() + if (!/^\d+$/.test(trimmed)) { + return 'Value must be a non-negative integer' + } + + const parsed = Number(trimmed) + if (!Number.isSafeInteger(parsed)) { + return 'Value must be a safe integer' + } + + return undefined +} + +const guidedCategories: GuidedCategory[] = [ + { + value: 'payments', + label: 'Payments', + settings: [ + { label: 'Enable payments', path: 'payments.enabled', type: 'boolean' }, + { + label: 'Payment processor', + path: 'payments.processor', + type: 'select', + options: ['zebedee', 'lnbits', 'lnurl', 'nodeless', 'opennode'], + }, + { + label: 'Admission fee enabled', + path: 'payments.feeSchedules.admission[0].enabled', + type: 'boolean', + }, + { + label: 'Admission fee amount (msats)', + path: 'payments.feeSchedules.admission[0].amount', + type: 'number', + validate: requireSafeNonNegativeInteger, + }, + ], + }, + { + value: 'network', + label: 'Network', + settings: [ + { + label: 'Relay URL', + path: 'info.relay_url', + type: 'string', + placeholder: 'wss://relay.example.com', + validate: requireNonEmpty, + }, + { + label: 'Relay name', + path: 'info.name', + type: 'string', + placeholder: 'relay.example.com', + validate: requireNonEmpty, + }, + { + label: 'Max payload size', + path: 'network.maxPayloadSize', + type: 'number', + validate: requireSafeNonNegativeInteger, + }, + ], + }, + { + value: 'limits', + label: 'Limits', + settings: [ + { + label: 'Rate limiter strategy', + path: 'limits.rateLimiter.strategy', + type: 'select', + options: ['ewma', 'sliding_window'], + }, + { + label: 'Primary event content max length', + path: 'limits.event.content[0].maxLength', + type: 'number', + validate: requireSafeNonNegativeInteger, + }, + { + label: 'Minimum pubkey balance', + path: 'limits.event.pubkey.minBalance', + type: 'number', + validate: requireSafeNonNegativeInteger, + }, + ], + }, +] + +const formatCurrentValue = (value: unknown): string => { + if (Array.isArray(value)) { + return value.length === 0 ? '[]' : value.join(', ') + } + + if (typeof value === 'string') { + return value + } + + if (value === undefined) { + return 'undefined' + } + + if (value === null) { + return 'null' + } + + if (typeof value === 'object') { + return JSON.stringify(value) + } + + return String(value) +} + +const getGuidedSettingValue = async (setting: GuidedSetting, currentValue: unknown) => { + switch (setting.type) { + case 'boolean': { + const answer = await tuiPrompts.confirm({ + message: `${setting.label} (current: ${formatCurrentValue(currentValue)})`, + initialValue: Boolean(currentValue), + }) + + if (tuiPrompts.isCancel(answer)) { + tuiPrompts.cancel('Cancelled') + return undefined + } + + return { + rawValue: String(answer), + valueType: 'inferred' as const, + } + } + case 'select': { + const options = (setting.options ?? []).map((option) => ({ + value: option, + label: option, + hint: option === currentValue ? 'current' : undefined, + })) + + const answer = await tuiPrompts.select({ + message: `${setting.label} (current: ${formatCurrentValue(currentValue)})`, + options: [...options, { value: 'back', label: 'Back' }], + }) + + if (tuiPrompts.isCancel(answer) || answer === 'back') { + tuiPrompts.cancel('Cancelled') + return undefined + } + + return { + rawValue: answer, + valueType: 'inferred' as const, + } + } + case 'stringArray': { + const defaultValue = Array.isArray(currentValue) ? currentValue.join(', ') : '' + const answer = await tuiPrompts.text({ + message: `${setting.label} (comma-separated)`, + defaultValue, + }) + + if (tuiPrompts.isCancel(answer)) { + tuiPrompts.cancel('Cancelled') + return undefined + } + + const parsed = answer + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + + return { + rawValue: JSON.stringify(parsed), + valueType: 'json' as const, + } + } + default: { + const answer = await tuiPrompts.text({ + message: `${setting.label} (current: ${formatCurrentValue(currentValue)})`, + defaultValue: currentValue === undefined || currentValue === null ? '' : String(currentValue), + placeholder: setting.placeholder, + validate: setting.validate, + }) + + if (tuiPrompts.isCancel(answer)) { + tuiPrompts.cancel('Cancelled') + return undefined + } + + return { + rawValue: answer, + valueType: 'inferred' as const, + } + } + } +} + +const runGuidedConfigureMenu = async (): Promise => { + const category = await tuiPrompts.select({ + message: 'Configuration category', + options: [...guidedCategories.map(({ value, label }) => ({ value, label })), { value: 'back', label: 'Back' }], + }) + + if (tuiPrompts.isCancel(category)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + if (category === 'back') { + return 0 + } + + const selectedCategory = guidedCategories.find((entry) => entry.value === category) + if (!selectedCategory) { + tuiPrompts.cancel('Unknown category') + return 1 + } + + const settings = loadMergedSettings() as unknown as Record + const setting = await tuiPrompts.select({ + message: `${selectedCategory.label} setting`, + options: [ + ...selectedCategory.settings.map((entry) => ({ + value: entry.path, + label: entry.label, + hint: `current: ${formatCurrentValue(getByPath(settings, entry.path))}`, + })), + { value: 'back', label: 'Back' }, + ], + }) + + if (tuiPrompts.isCancel(setting)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + if (setting === 'back') { + return 0 + } + + const selectedSetting = selectedCategory.settings.find((entry) => entry.path === setting) + if (!selectedSetting) { + tuiPrompts.cancel('Unknown setting') + return 1 + } + + const currentValue = getByPath(settings, selectedSetting.path) + const nextValue = await getGuidedSettingValue(selectedSetting, currentValue) + if (!nextValue) { + return 1 + } + + const confirmedSave = await tuiPrompts.confirm({ + message: `Save ${selectedSetting.label}?`, + initialValue: true, + }) + if (tuiPrompts.isCancel(confirmedSave) || !confirmedSave) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + const restart = await tuiPrompts.confirm({ + message: 'Restart relay after this setting change?', + initialValue: false, + }) + if (tuiPrompts.isCancel(restart)) { + tuiPrompts.cancel('Cancelled') + return 1 + } + + return runConfigSet(selectedSetting.path, nextValue.rawValue, { + restart, + validate: true, + valueType: nextValue.valueType, + }) +} + export const runConfigureMenu = async (): Promise => { const action = await tuiPrompts.select({ message: 'Configuration action', options: [ { value: 'list', label: 'List all settings' }, - { value: 'get', label: 'Get setting by dot-path' }, - { value: 'set', label: 'Set setting by dot-path' }, + { value: 'guided', label: 'Guided edit (common settings)' }, + { value: 'get', label: 'Advanced get by dot-path' }, + { value: 'set', label: 'Advanced set by dot-path' }, { value: 'validate', label: 'Validate settings' }, { value: 'back', label: 'Back' }, ], @@ -55,6 +352,10 @@ export const runConfigureMenu = async (): Promise => { return runConfigValidate() } + if (action === 'guided') { + return runGuidedConfigureMenu() + } + const category = await tuiPrompts.select({ message: 'Configuration category', options: [...getCategoryOptions(), { value: 'back', label: 'Back' }], diff --git a/src/cli/utils/process.ts b/src/cli/utils/process.ts index cfa702f9..a574de08 100644 --- a/src/cli/utils/process.ts +++ b/src/cli/utils/process.ts @@ -4,6 +4,7 @@ export type RunOptions = { cwd?: string env?: NodeJS.ProcessEnv stdio?: 'inherit' | 'pipe' + timeoutMs?: number } export const runCommand = (command: string, args: string[], options: RunOptions = {}): Promise => { @@ -15,8 +16,21 @@ export const runCommand = (command: string, args: string[], options: RunOptions shell: false, }) + const timer = + typeof options.timeoutMs === 'number' + ? setTimeout(() => { + child.kill('SIGTERM') + }, options.timeoutMs) + : undefined + child.on('error', reject) - child.on('close', (code) => resolve(code ?? 1)) + child.on('close', (code) => { + if (timer) { + clearTimeout(timer) + } + + resolve(code ?? 1) + }) }) } @@ -36,6 +50,13 @@ export const runCommandWithOutput = ( shell: false, }) + const timer = + typeof options.timeoutMs === 'number' + ? setTimeout(() => { + child.kill('SIGTERM') + }, options.timeoutMs) + : undefined + child.stdout.on('data', (chunk) => { stdout += chunk.toString() }) @@ -46,6 +67,10 @@ export const runCommandWithOutput = ( child.on('error', reject) child.on('close', (code) => { + if (timer) { + clearTimeout(timer) + } + resolve({ code: code ?? 1, stdout, diff --git a/test/unit/cli/cli.integration.spec.ts b/test/unit/cli/cli.integration.spec.ts index 5865afeb..d7ae5c3d 100644 --- a/test/unit/cli/cli.integration.spec.ts +++ b/test/unit/cli/cli.integration.spec.ts @@ -45,6 +45,39 @@ const runCli = (args: string[], env: NodeJS.ProcessEnv = {}): Promise }) } +const runNpmCli = (args: string[], env: NodeJS.ProcessEnv = {}): Promise => { + return new Promise((resolve, reject) => { + const child = spawn('npm', ['run', 'cli', '--', ...args], { + cwd: projectRoot, + env: { + ...process.env, + ...env, + }, + stdio: 'pipe', + }) + + let stdout = '' + let stderr = '' + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString() + }) + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString() + }) + + child.on('error', reject) + child.on('close', (code) => { + resolve({ + code: code ?? 1, + stdout, + stderr, + }) + }) + }) +} + const createShimCommand = (dir: string, name: string, scriptBody: string) => { const target = path.join(dir, name) fs.writeFileSync( @@ -68,6 +101,14 @@ describe('cli integration (spawn)', function () { expect(result.stdout).to.include('clean') }) + it('supports npm run cli as the documented entry point', async () => { + const result = await runNpmCli(['--help']) + + expect(result.code).to.equal(0) + expect(result.stdout).to.include('Usage:') + expect(result.stdout).to.include('start [...args]') + }) + it('keeps package bin mapping aligned with TypeScript build output path', () => { const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8')) as { bin?: string | { nostream?: string } @@ -184,6 +225,42 @@ describe('cli integration (spawn)', function () { expect(infoHelp.code).to.equal(0) expect(infoHelp.stdout).to.include('--i2p-hostname') + expect(infoHelp.stdout).to.include('--json') + }) + + it('supports json output for info and config reads', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-json-read-')) + + const infoResult = await runCli(['info', '--json'], { NOSTR_CONFIG_DIR: configDir }) + expect(infoResult.code).to.equal(0) + expect(() => JSON.parse(infoResult.stdout)).to.not.throw() + expect(JSON.parse(infoResult.stdout)).to.have.property('relay') + + const configListResult = await runCli(['config', 'list', '--json'], { NOSTR_CONFIG_DIR: configDir }) + expect(configListResult.code).to.equal(0) + expect(() => JSON.parse(configListResult.stdout)).to.not.throw() + expect(JSON.parse(configListResult.stdout)).to.have.property('payments') + + const configGetResult = await runCli(['config', 'get', 'payments.enabled', '--json'], { + NOSTR_CONFIG_DIR: configDir, + }) + expect(configGetResult.code).to.equal(0) + expect(JSON.parse(configGetResult.stdout)).to.equal(false) + }) + + it('prints json errors for read failures in json mode', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-cli-json-error-')) + + const configGetResult = await runCli(['config', 'get', 'payments.fakeField', '--json'], { + NOSTR_CONFIG_DIR: configDir, + }) + expect(configGetResult.code).to.equal(1) + expect(JSON.parse(configGetResult.stderr)).to.deep.equal({ + error: { + message: 'Path not found: payments.fakeField', + code: 1, + }, + }) }) it('validates nginx start requirements', async () => { diff --git a/test/unit/cli/docs.spec.ts b/test/unit/cli/docs.spec.ts new file mode 100644 index 00000000..c4bbf565 --- /dev/null +++ b/test/unit/cli/docs.spec.ts @@ -0,0 +1,41 @@ +import { expect } from 'chai' +import fs from 'fs' +import path from 'path' + +describe('cli documentation alignment', () => { + const projectRoot = process.cwd() + + it('documents removed legacy wrapper scripts explicitly', () => { + const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf-8') + const cliDoc = fs.readFileSync(path.join(projectRoot, 'CLI.md'), 'utf-8') + + expect(readme).to.include('The old shell wrapper scripts are no longer shipped in `scripts/`.') + expect(cliDoc).to.include('The old shell wrapper scripts are no longer shipped in `scripts/`.') + }) + + it('documents the guided TUI configure flow and fallback behavior', () => { + const cliDoc = fs.readFileSync(path.join(projectRoot, 'CLI.md'), 'utf-8') + + expect(cliDoc).to.include('When run with no arguments in an interactive terminal, `nostream` launches an interactive TUI.') + expect(cliDoc).to.include('Configure menu offers guided editing for common categories such as payments, network, and limits.') + expect(cliDoc).to.include('Advanced dot-path get/set remains available for full settings access.') + }) + + it('does not ship removed legacy wrapper scripts', () => { + const removedWrappers = [ + 'start', + 'start_with_tor', + 'start_with_i2p', + 'start_with_nginx', + 'stop', + 'print_tor_hostname', + 'print_i2p_hostname', + 'update', + 'clean', + ] + + for (const wrapper of removedWrappers) { + expect(fs.existsSync(path.join(projectRoot, 'scripts', wrapper))).to.equal(false, wrapper) + } + }) +}) diff --git a/test/unit/cli/tui.spec.ts b/test/unit/cli/tui.spec.ts index 6656e9b9..5d0a71af 100644 --- a/test/unit/cli/tui.spec.ts +++ b/test/unit/cli/tui.spec.ts @@ -45,6 +45,91 @@ describe('cli tui menus', () => { expect(code).to.equal(0) }) + it('routes guided configure values into config set', async () => { + sinon + .stub(tuiPrompts, 'select') + .onFirstCall() + .resolves('guided' as any) + .onSecondCall() + .resolves('payments' as any) + .onThirdCall() + .resolves('payments.processor' as any) + .onCall(3) + .resolves('lnbits' as any) + sinon + .stub(tuiPrompts, 'confirm') + .onFirstCall() + .resolves(true as any) + .onSecondCall() + .resolves(false as any) + sinon.stub(tuiPrompts, 'isCancel').returns(false) + sinon.stub(configCommands, 'getConfigTopLevelCategories').returns(['payments', 'limits', 'network']) + const runConfigSet = sinon.stub(configCommands, 'runConfigSet').resolves(0) + + const code = await configureMenu.runConfigureMenu() + + expect(code).to.equal(0) + expect(runConfigSet.calledOnceWithExactly('payments.processor', 'lnbits', { + restart: false, + validate: true, + valueType: 'inferred', + })).to.equal(true) + }) + + it('rejects invalid guided numeric input before writing', async () => { + sinon + .stub(tuiPrompts, 'select') + .onFirstCall() + .resolves('guided' as any) + .onSecondCall() + .resolves('limits' as any) + .onThirdCall() + .resolves('limits.event.content[0].maxLength' as any) + const textStub = sinon.stub(tuiPrompts, 'text').callsFake(async (options: any) => { + expect(options.validate('bad')).to.equal('Value must be a non-negative integer') + expect(options.validate('2048')).to.equal(undefined) + return '2048' + }) + sinon + .stub(tuiPrompts, 'confirm') + .onFirstCall() + .resolves(true as any) + .onSecondCall() + .resolves(false as any) + sinon.stub(tuiPrompts, 'isCancel').returns(false) + const runConfigSet = sinon.stub(configCommands, 'runConfigSet').resolves(0) + + const code = await configureMenu.runConfigureMenu() + + expect(code).to.equal(0) + expect(textStub.calledOnce).to.equal(true) + expect(runConfigSet.calledOnceWithExactly('limits.event.content[0].maxLength', '2048', { + restart: false, + validate: true, + valueType: 'inferred', + })).to.equal(true) + }) + + it('keeps advanced configure get action available', async () => { + sinon + .stub(tuiPrompts, 'select') + .onFirstCall() + .resolves('get' as any) + .onSecondCall() + .resolves('other' as any) + sinon + .stub(tuiPrompts, 'text') + .resolves('payments.enabled' as any) + sinon.stub(tuiPrompts, 'confirm').resolves(true as any) + sinon.stub(tuiPrompts, 'isCancel').returns(false) + const runGet = sinon.stub(configCommands, 'runConfigGet').resolves(0) + + const code = await configureMenu.runConfigureMenu() + + expect(code).to.equal(0) + expect(runGet.calledOnceWithExactly('payments.enabled')).to.equal(true) + }) + it('routes start menu prompt values into start command', async () => { sinon.stub(tuiPrompts, 'select').resolves('continue' as any) sinon From 7f5fb26175a7fb7bf1766b6ede97f80d5c37d22e Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Sat, 25 Apr 2026 15:10:21 +0300 Subject: [PATCH 10/20] feat(cli): enhance setup and info commands with JSON output and secret management --- package.json | 13 +++ scripts/verify-cli-build.js | 54 +++++++++++- src/cli/commands/info.ts | 39 +++++++++ src/cli/commands/setup.ts | 110 +++++++++++++++++++----- test/unit/cli/cli.integration.spec.ts | 58 +++++++++++++ test/unit/cli/info.spec.ts | 117 ++++++++++++++++++++++++++ test/unit/cli/setup.spec.ts | 90 ++++++++++++++++++++ 7 files changed, 458 insertions(+), 23 deletions(-) create mode 100644 test/unit/cli/info.spec.ts create mode 100644 test/unit/cli/setup.spec.ts diff --git a/package.json b/package.json index 281cc722..959e64ad 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,18 @@ "bin": { "nostream": "./dist/src/cli/index.js" }, + "files": [ + "dist", + "resources", + "nginx", + "i2p", + "docker-compose*.yml", + "postgresql.conf", + ".env.example", + "README.md", + "CLI.md", + "CONFIGURATION.md" + ], "scripts": { "cli": "node --env-file-if-exists=.env -r ts-node/register src/cli/index.ts", "dev": "node --env-file-if-exists=.env -r ts-node/register src/index.ts", @@ -76,6 +88,7 @@ "docker:test:integration": "npm run docker:integration:run -- npm run test:integration", "docker:cover:integration": "npm run docker:integration:run -- npm run cover:integration", "postdocker:integration:run": "docker compose -f ./test/integration/docker-compose.yml down", + "prepack": "npm run build", "prepare": "husky install || exit 0", "changeset:version": "changeset version && npm install", "changeset:publish": "changeset publish" diff --git a/scripts/verify-cli-build.js b/scripts/verify-cli-build.js index 566359cb..13c7af4d 100644 --- a/scripts/verify-cli-build.js +++ b/scripts/verify-cli-build.js @@ -18,6 +18,24 @@ if (!fs.existsSync(binPath)) { process.exit(1) } +const requiredPackedFiles = [ + 'package.json', + relBin.replace(/^\.\//, ''), + 'resources/default-settings.yaml', + 'docker-compose.yml', +] + +const parseNpmJsonOutput = (output) => { + const start = output.indexOf('[') + const end = output.lastIndexOf(']') + + if (start === -1 || end === -1 || end < start) { + throw new Error('No JSON payload found in npm output') + } + + return JSON.parse(output.slice(start, end + 1)) +} + const result = spawnSync('node', [binPath, '--help'], { cwd: path.resolve(__dirname, '..'), env: process.env, @@ -40,4 +58,38 @@ if (!result.stdout.includes('Usage:')) { process.exit(1) } -console.log(`Verified CLI build entrypoint: ${relBin}`) +const packResult = spawnSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + cwd: path.resolve(__dirname, '..'), + env: process.env, + encoding: 'utf-8', +}) + +if (packResult.status !== 0) { + console.error(`npm pack dry-run failed (exit ${packResult.status ?? 1})`) + if (packResult.stdout) { + process.stderr.write(packResult.stdout) + } + if (packResult.stderr) { + process.stderr.write(packResult.stderr) + } + process.exit(packResult.status ?? 1) +} + +let packed +try { + packed = parseNpmJsonOutput(packResult.stdout) +} catch (error) { + console.error('Failed to parse npm pack --json output') + process.stderr.write(String(error)) + process.exit(1) +} + +const files = new Set((packed[0]?.files ?? []).map((file) => file.path)) +for (const requiredFile of requiredPackedFiles) { + if (!files.has(requiredFile)) { + console.error(`Packed npm artifact is missing required file: ${requiredFile}`) + process.exit(1) + } +} + +console.log(`Verified CLI build entrypoint and package contents: ${relBin}`) diff --git a/src/cli/commands/info.ts b/src/cli/commands/info.ts index 85cbb154..513e3e4d 100644 --- a/src/cli/commands/info.ts +++ b/src/cli/commands/info.ts @@ -14,6 +14,15 @@ type InfoOptions = { json?: boolean } +type I2PGuidancePayload = { + i2pHostnames: string[] + keysFile: string + guidance?: { + webConsoleUrl: string + consoleQueryCommand: string + } +} + const getEventCount = async (): Promise => { const db = knex({ client: 'pg', @@ -102,6 +111,10 @@ const writeJson = (value: unknown): void => { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`) } +const writeJsonError = (message: string, code = 1): void => { + process.stderr.write(`${JSON.stringify({ error: { message, code } })}\n`) +} + export const getInfoPayload = async () => { const settings = loadMergedSettings() const torHostnamePath = getTorHostnamePath() @@ -155,8 +168,22 @@ export const runInfo = async (options: InfoOptions): Promise => { if (options.i2pHostname) { const keysFile = getProjectPath('.nostr', 'i2p', 'data', 'nostream.dat') + const i2pGuidance: I2PGuidancePayload = { + i2pHostnames: [], + keysFile, + guidance: { + webConsoleUrl: 'http://127.0.0.1:7070/?page=i2p_tunnels', + consoleQueryCommand: + "docker exec i2pd wget -qO- 'http://127.0.0.1:7070/?page=i2p_tunnels' | grep -oE '[a-z2-7]{52}\\\\.b32\\\\.i2p' | sort -u", + }, + } if (!fs.existsSync(keysFile)) { + if (options.json) { + writeJsonError(`I2P destination keys not found. Is the i2pd container running? Expected: ${keysFile}`) + return 1 + } + logError('I2P destination keys not found. Is the i2pd container running?') logError(`Expected: ${keysFile}`) return 1 @@ -172,12 +199,24 @@ export const runInfo = async (options: InfoOptions): Promise => { const matches = new Set((`${result.stdout}\n${result.stderr}`).match(/[a-z2-7]{52}\.b32\.i2p/g) ?? []) if (matches.size > 0) { + if (options.json) { + writeJson({ + i2pHostnames: [...matches], + }) + return 0 + } + for (const hostname of matches) { logInfo(hostname) } return 0 } + if (options.json) { + writeJson(i2pGuidance) + return 0 + } + logInfo(`I2P destination keys exist at: ${keysFile}`) logInfo('') logInfo('To find your nostream .b32.i2p address, use one of these methods:') diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index f0355120..d1d1cdb6 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -1,4 +1,5 @@ import fs from 'fs' +import { randomBytes } from 'crypto' import { intro, outro, confirm, text, isCancel, cancel } from '@clack/prompts' import { ensureConfigBootstrap } from '../utils/bootstrap' @@ -10,49 +11,114 @@ type SetupOptions = { start?: boolean } -const ensureEnvFile = async (assumeYes: boolean): Promise => { - const envPath = getProjectPath('.env') - const envExamplePath = getProjectPath('.env.example') +const SECRET_PLACEHOLDER = 'change_me_to_something_long_and_random' - if (fs.existsSync(envPath)) { - return - } +const readEnvSecret = (content: string): string | undefined => { + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#') || !trimmed.startsWith('SECRET=')) { + continue + } - if (fs.existsSync(envExamplePath)) { - fs.copyFileSync(envExamplePath, envPath) - } else { - fs.writeFileSync(envPath, '', 'utf-8') + const [rawValue] = trimmed.slice('SECRET='.length).split('#', 1) + return rawValue.trim() } - const current = fs.readFileSync(envPath, 'utf-8') - if (current.includes('SECRET=')) { - return - } + return undefined +} - let secret = process.env.SECRET +const needsSecretReplacement = (secret: string | undefined): boolean => { + return !secret || secret === SECRET_PLACEHOLDER +} + +const resolveSecret = async (assumeYes: boolean): Promise => { + if (process.env.SECRET?.trim()) { + return process.env.SECRET.trim() + } if (!assumeYes && process.stdin.isTTY) { const value = await text({ message: 'SECRET env var value (hex recommended)', placeholder: 'openssl rand -hex 128', - defaultValue: secret, validate: (input) => (input.trim() ? undefined : 'SECRET is required'), }) if (isCancel(value)) { cancel('Setup cancelled') - process.exitCode = 1 - return + throw new Error('SETUP_CANCELLED') + } + + return value.trim() + } + + return randomBytes(64).toString('hex') +} + +const upsertSecret = (content: string, secret: string): string => { + const normalized = content.length > 0 ? content : '' + const lines = normalized.split(/\r?\n/) + let replaced = false + + const nextLines = lines.map((line) => { + if (replaced) { + return line + } + + const trimmed = line.trim() + if (!trimmed.startsWith('SECRET=') || trimmed.startsWith('#')) { + return line } - secret = value + replaced = true + const commentIndex = line.indexOf('#') + const commentSuffix = commentIndex >= 0 ? line.slice(commentIndex).trimEnd() : '' + return commentSuffix ? `SECRET=${secret} ${commentSuffix}` : `SECRET=${secret}` + }) + + if (!replaced) { + if (nextLines.length > 0 && nextLines[nextLines.length - 1] !== '') { + nextLines.push(`SECRET=${secret}`) + } else if (nextLines.length === 0) { + nextLines.push(`SECRET=${secret}`) + } else { + nextLines[nextLines.length - 1] = `SECRET=${secret}` + nextLines.push('') + } } - if (!secret) { - throw new Error('SECRET is required. Set SECRET env var or run setup interactively.') + return nextLines.join('\n') +} + +const ensureEnvFile = async (assumeYes: boolean): Promise => { + const envPath = getProjectPath('.env') + const envExamplePath = getProjectPath('.env.example') + + if (!fs.existsSync(envPath)) { + if (fs.existsSync(envExamplePath)) { + fs.copyFileSync(envExamplePath, envPath) + } else { + fs.writeFileSync(envPath, '', 'utf-8') + } + } + + const current = fs.readFileSync(envPath, 'utf-8') + + if (!needsSecretReplacement(readEnvSecret(current))) { + return + } + + let secret: string + try { + secret = await resolveSecret(assumeYes) + } catch (error) { + if (error instanceof Error && error.message === 'SETUP_CANCELLED') { + process.exitCode = 1 + return + } + throw error } - fs.appendFileSync(envPath, `\nSECRET=${secret}\n`, 'utf-8') + fs.writeFileSync(envPath, upsertSecret(current, secret), 'utf-8') } export const runSetup = async (options: SetupOptions): Promise => { diff --git a/test/unit/cli/cli.integration.spec.ts b/test/unit/cli/cli.integration.spec.ts index d7ae5c3d..79f35cb0 100644 --- a/test/unit/cli/cli.integration.spec.ts +++ b/test/unit/cli/cli.integration.spec.ts @@ -88,6 +88,47 @@ const createShimCommand = (dir: string, name: string, scriptBody: string) => { fs.chmodSync(target, 0o755) } +const parseNpmJsonOutput = (output: string): T => { + const start = output.indexOf('[') + const end = output.lastIndexOf(']') + + if (start === -1 || end === -1 || end < start) { + throw new Error(`No JSON payload found in npm output: ${output}`) + } + + return JSON.parse(output.slice(start, end + 1)) as T +} + +const runCommand = (command: string, args: string[]): Promise => { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: projectRoot, + env: process.env, + stdio: 'pipe', + }) + + let stdout = '' + let stderr = '' + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString() + }) + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString() + }) + + child.on('error', reject) + child.on('close', (code) => { + resolve({ + code: code ?? 1, + stdout, + stderr, + }) + }) + }) +} + describe('cli integration (spawn)', function () { this.timeout(30000) @@ -111,10 +152,27 @@ describe('cli integration (spawn)', function () { it('keeps package bin mapping aligned with TypeScript build output path', () => { const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8')) as { + files?: string[] bin?: string | { nostream?: string } } const binPath = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.nostream expect(binPath).to.equal('./dist/src/cli/index.js') + expect(pkg.files).to.include('dist') + }) + + it('packs the built CLI and runtime assets required for installation', async () => { + const result = await runCommand('npm', ['pack', '--dry-run', '--json', '--ignore-scripts']) + + expect(result.code).to.equal(0) + const packSummary = parseNpmJsonOutput + }>>(result.stdout) + const packedFiles = new Set(packSummary[0].files.map((file) => file.path)) + + expect(packedFiles.has('package.json')).to.equal(true) + expect(packedFiles.has('dist/src/cli/index.js')).to.equal(true) + expect(packedFiles.has('resources/default-settings.yaml')).to.equal(true) + expect(packedFiles.has('docker-compose.yml')).to.equal(true) }) it('shows nested subcommand help', async () => { diff --git a/test/unit/cli/info.spec.ts b/test/unit/cli/info.spec.ts new file mode 100644 index 00000000..5e4d5cff --- /dev/null +++ b/test/unit/cli/info.spec.ts @@ -0,0 +1,117 @@ +import { expect } from 'chai' +import fs from 'fs' +import { createRequire } from 'module' +import path from 'path' +import sinon from 'sinon' + +const require = createRequire(import.meta.url) +const infoCommand = require('../../../dist/src/cli/commands/info.js') as typeof import('../../../dist/src/cli/commands/info.js') +const configUtils = require('../../../dist/src/cli/utils/config.js') as typeof import('../../../dist/src/cli/utils/config.js') +const processUtils = require('../../../dist/src/cli/utils/process.js') as typeof import('../../../dist/src/cli/utils/process.js') + +describe('runInfo', () => { + const keysFile = path.join(process.cwd(), '.nostr', 'i2p', 'data', 'nostream.dat') + + let stdout = '' + let stderr = '' + + beforeEach(() => { + sinon.stub(configUtils, 'loadMergedSettings').returns({}) + sinon.stub(process.stdout, 'write').callsFake(((chunk: string | Uint8Array) => { + stdout += String(chunk) + return true + }) as any) + sinon.stub(process.stderr, 'write').callsFake(((chunk: string | Uint8Array) => { + stderr += String(chunk) + return true + }) as any) + }) + + afterEach(() => { + stdout = '' + stderr = '' + sinon.restore() + }) + + it('prints detected I2P hostnames as JSON', async () => { + sinon.stub(fs, 'existsSync').callsFake((target) => String(target).endsWith('nostream.dat')) + sinon + .stub(processUtils, 'runCommandWithOutput') + .onFirstCall() + .resolves({ code: 1, stdout: '', stderr: '' }) + .onSecondCall() + .resolves({ + code: 0, + stdout: 'alphaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.b32.i2p\n', + stderr: 'betabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.b32.i2p\n', + }) + + const code = await infoCommand.runInfo({ i2pHostname: true, json: true }) + + expect(code).to.equal(0) + expect(JSON.parse(stdout)).to.deep.equal({ + i2pHostnames: [ + 'alphaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.b32.i2p', + 'betabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.b32.i2p', + ], + }) + expect(stderr).to.equal('') + }) + + it('prints a JSON error when I2P keys are missing', async () => { + sinon.stub(fs, 'existsSync').returns(false) + sinon.stub(processUtils, 'runCommandWithOutput').resolves({ code: 1, stdout: '', stderr: '' }) + + const code = await infoCommand.runInfo({ i2pHostname: true, json: true }) + + expect(code).to.equal(1) + expect(JSON.parse(stderr)).to.deep.equal({ + error: { + message: `I2P destination keys not found. Is the i2pd container running? Expected: ${keysFile}`, + code: 1, + }, + }) + expect(stdout).to.equal('') + }) + + it('prints JSON guidance when no I2P hostname can be extracted', async () => { + sinon.stub(fs, 'existsSync').callsFake((target) => String(target).endsWith('nostream.dat')) + sinon + .stub(processUtils, 'runCommandWithOutput') + .onFirstCall() + .resolves({ code: 1, stdout: '', stderr: '' }) + .onSecondCall() + .resolves({ code: 0, stdout: '', stderr: '' }) + + const code = await infoCommand.runInfo({ i2pHostname: true, json: true }) + + expect(code).to.equal(0) + expect(JSON.parse(stdout)).to.deep.equal({ + i2pHostnames: [], + keysFile, + guidance: { + webConsoleUrl: 'http://127.0.0.1:7070/?page=i2p_tunnels', + consoleQueryCommand: + "docker exec i2pd wget -qO- 'http://127.0.0.1:7070/?page=i2p_tunnels' | grep -oE '[a-z2-7]{52}\\\\.b32\\\\.i2p' | sort -u", + }, + }) + expect(stderr).to.equal('') + }) + + it('keeps non-json I2P hostname output human-readable', async () => { + sinon.stub(fs, 'existsSync').callsFake((target) => String(target).endsWith('nostream.dat')) + sinon + .stub(processUtils, 'runCommandWithOutput') + .onFirstCall() + .resolves({ code: 1, stdout: '', stderr: '' }) + .onSecondCall() + .resolves({ code: 0, stdout: '', stderr: '' }) + + const code = await infoCommand.runInfo({ i2pHostname: true }) + + expect(code).to.equal(0) + expect(stdout).to.include(`I2P destination keys exist at: ${keysFile}`) + expect(stdout).to.include('To find your nostream .b32.i2p address, use one of these methods:') + expect(stderr).to.equal('') + }) +}) diff --git a/test/unit/cli/setup.spec.ts b/test/unit/cli/setup.spec.ts new file mode 100644 index 00000000..cf3f7c76 --- /dev/null +++ b/test/unit/cli/setup.spec.ts @@ -0,0 +1,90 @@ +import { expect } from 'chai' +import fs from 'fs' +import os from 'os' +import path from 'path' + +const setupCommand = await import('../../../dist/src/cli/commands/setup.js') + +describe('runSetup', () => { + const originalCwd = process.cwd() + const originalSecret = process.env.SECRET + + let tempDir: string + + const writeDefaultSettings = () => { + fs.mkdirSync(path.join(tempDir, 'resources'), { recursive: true }) + fs.writeFileSync(path.join(tempDir, 'resources', 'default-settings.yaml'), 'payments:\n enabled: false\n', 'utf-8') + } + + const readEnv = () => { + return fs.readFileSync(path.join(tempDir, '.env'), 'utf-8') + } + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-setup-')) + process.chdir(tempDir) + writeDefaultSettings() + delete process.env.SECRET + }) + + afterEach(() => { + process.chdir(originalCwd) + if (originalSecret === undefined) { + delete process.env.SECRET + } else { + process.env.SECRET = originalSecret + } + + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + it('copies .env.example and replaces the placeholder secret', async () => { + fs.writeFileSync( + path.join(tempDir, '.env.example'), + 'SECRET=change_me_to_something_long_and_random # Generate: openssl rand -hex 128\nFOO=bar\n', + 'utf-8', + ) + process.env.SECRET = 'replacement-secret' + + const code = await setupCommand.runSetup({ yes: true }) + + expect(code).to.equal(0) + const envContents = readEnv() + expect(envContents).to.include('SECRET=replacement-secret # Generate: openssl rand -hex 128') + expect(envContents).to.include('FOO=bar') + expect(envContents.match(/^SECRET=/gm)).to.have.length(1) + }) + + it('preserves an existing non-placeholder secret', async () => { + fs.writeFileSync(path.join(tempDir, '.env'), 'SECRET=real-secret\nFOO=bar\n', 'utf-8') + + const code = await setupCommand.runSetup({ yes: true }) + + expect(code).to.equal(0) + expect(readEnv()).to.equal('SECRET=real-secret\nFOO=bar\n') + }) + + it('fills an empty secret from process.env.SECRET', async () => { + fs.writeFileSync(path.join(tempDir, '.env'), 'SECRET= # existing comment\nFOO=bar\n', 'utf-8') + process.env.SECRET = 'env-secret' + + const code = await setupCommand.runSetup({ yes: true }) + + expect(code).to.equal(0) + const envContents = readEnv() + expect(envContents).to.include('SECRET=env-secret # existing comment') + expect(envContents.match(/^SECRET=/gm)).to.have.length(1) + }) + + it('generates a secure fallback secret in non-interactive mode', async () => { + fs.writeFileSync(path.join(tempDir, '.env.example'), 'SECRET=change_me_to_something_long_and_random\n', 'utf-8') + + const code = await setupCommand.runSetup({ yes: true }) + + expect(code).to.equal(0) + const generatedSecret = readEnv().match(/^SECRET=([a-f0-9]+)$/m)?.[1] + expect(generatedSecret).to.not.equal(undefined) + expect(generatedSecret).to.not.equal('change_me_to_something_long_and_random') + expect(generatedSecret).to.have.length(128) + }) +}) From 0ca25c767645a69ed36e35309170423de44f413a Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Sat, 25 Apr 2026 16:37:09 +0300 Subject: [PATCH 11/20] feat(cli): refactor setup prompts and error handling in runSetup function --- src/cli/commands/setup.ts | 51 +++++++++++++++++++++++++------------ test/unit/cli/setup.spec.ts | 27 ++++++++++++++++++++ 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index d1d1cdb6..7de3fa21 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -13,6 +13,22 @@ type SetupOptions = { const SECRET_PLACEHOLDER = 'change_me_to_something_long_and_random' +export const setupPrompts = { + intro, + outro, + confirm, + text, + isCancel, + cancel, +} + +class SetupCancelledError extends Error { + constructor() { + super('Setup cancelled') + this.name = 'SetupCancelledError' + } +} + const readEnvSecret = (content: string): string | undefined => { for (const line of content.split(/\r?\n/)) { const trimmed = line.trim() @@ -37,15 +53,15 @@ const resolveSecret = async (assumeYes: boolean): Promise => { } if (!assumeYes && process.stdin.isTTY) { - const value = await text({ + const value = await setupPrompts.text({ message: 'SECRET env var value (hex recommended)', placeholder: 'openssl rand -hex 128', validate: (input) => (input.trim() ? undefined : 'SECRET is required'), }) - if (isCancel(value)) { - cancel('Setup cancelled') - throw new Error('SETUP_CANCELLED') + if (setupPrompts.isCancel(value)) { + setupPrompts.cancel('Setup cancelled') + throw new SetupCancelledError() } return value.trim() @@ -89,7 +105,7 @@ const upsertSecret = (content: string, secret: string): string => { return nextLines.join('\n') } -const ensureEnvFile = async (assumeYes: boolean): Promise => { +const ensureEnvFile = async (assumeYes: boolean): Promise => { const envPath = getProjectPath('.env') const envExamplePath = getProjectPath('.env.example') @@ -104,35 +120,38 @@ const ensureEnvFile = async (assumeYes: boolean): Promise => { const current = fs.readFileSync(envPath, 'utf-8') if (!needsSecretReplacement(readEnvSecret(current))) { - return + return true } let secret: string try { secret = await resolveSecret(assumeYes) } catch (error) { - if (error instanceof Error && error.message === 'SETUP_CANCELLED') { - process.exitCode = 1 - return + if (error instanceof SetupCancelledError) { + return false } throw error } fs.writeFileSync(envPath, upsertSecret(current, secret), 'utf-8') + return true } export const runSetup = async (options: SetupOptions): Promise => { - intro('Nostream setup') + setupPrompts.intro('Nostream setup') ensureConfigBootstrap() - await ensureEnvFile(Boolean(options.yes)) + const shouldContinue = await ensureEnvFile(Boolean(options.yes)) + if (!shouldContinue) { + return 1 + } let shouldStart = Boolean(options.start) if (!options.yes && !options.start && process.stdin.isTTY) { - const answer = await confirm({ message: 'Start relay now?', initialValue: true }) - if (isCancel(answer)) { - cancel('Setup cancelled') + const answer = await setupPrompts.confirm({ message: 'Start relay now?', initialValue: true }) + if (setupPrompts.isCancel(answer)) { + setupPrompts.cancel('Setup cancelled') return 1 } @@ -141,10 +160,10 @@ export const runSetup = async (options: SetupOptions): Promise => { if (shouldStart) { const code = await runStart({}, []) - outro(code === 0 ? 'Setup complete' : 'Setup finished with errors') + setupPrompts.outro(code === 0 ? 'Setup complete' : 'Setup finished with errors') return code } - outro('Setup complete') + setupPrompts.outro('Setup complete') return 0 } diff --git a/test/unit/cli/setup.spec.ts b/test/unit/cli/setup.spec.ts index cf3f7c76..13fb82e3 100644 --- a/test/unit/cli/setup.spec.ts +++ b/test/unit/cli/setup.spec.ts @@ -2,12 +2,14 @@ import { expect } from 'chai' import fs from 'fs' import os from 'os' import path from 'path' +import sinon from 'sinon' const setupCommand = await import('../../../dist/src/cli/commands/setup.js') describe('runSetup', () => { const originalCwd = process.cwd() const originalSecret = process.env.SECRET + const originalStdinIsTTY = process.stdin.isTTY let tempDir: string @@ -28,7 +30,9 @@ describe('runSetup', () => { }) afterEach(() => { + sinon.restore() process.chdir(originalCwd) + process.stdin.isTTY = originalStdinIsTTY if (originalSecret === undefined) { delete process.env.SECRET } else { @@ -87,4 +91,27 @@ describe('runSetup', () => { expect(generatedSecret).to.not.equal('change_me_to_something_long_and_random') expect(generatedSecret).to.have.length(128) }) + + it('returns 1 when setup is cancelled while entering the secret and does not continue', async () => { + const cancelToken = Symbol('cancel') + + process.stdin.isTTY = true + fs.writeFileSync(path.join(tempDir, '.env.example'), 'SECRET=change_me_to_something_long_and_random\n', 'utf-8') + + const textStub = sinon.stub(setupCommand.setupPrompts, 'text').resolves(cancelToken as any) + const isCancelStub = sinon.stub(setupCommand.setupPrompts, 'isCancel').callsFake((value) => value === cancelToken) + const cancelStub = sinon.stub(setupCommand.setupPrompts, 'cancel') + const confirmStub = sinon.stub(setupCommand.setupPrompts, 'confirm') + const outroStub = sinon.stub(setupCommand.setupPrompts, 'outro') + + const code = await setupCommand.runSetup({ yes: false }) + + expect(code).to.equal(1) + expect(textStub.calledOnce).to.equal(true) + expect(isCancelStub.calledOnceWithExactly(cancelToken)).to.equal(true) + expect(cancelStub.calledOnceWithExactly('Setup cancelled')).to.equal(true) + expect(confirmStub.notCalled).to.equal(true) + expect(outroStub.notCalled).to.equal(true) + expect(readEnv()).to.equal('SECRET=change_me_to_something_long_and_random\n') + }) }) From 3b54936b241d41d6b09462e47a81d3535fac9844 Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Sat, 25 Apr 2026 16:48:33 +0300 Subject: [PATCH 12/20] feat(cli): update CLI version to match package.json and refactor import statements in tests --- src/cli/index.ts | 3 ++- test/unit/cli/info.spec.ts | 18 ++++++++---------- test/unit/cli/setup.spec.ts | 2 +- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 3559a08b..8366eabe 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node const { cac } = require('cac') +import packageJson from '../../package.json' import { runStart } from './commands/start' import { runStop } from './commands/stop' import { runInfo } from './commands/info' @@ -419,7 +420,7 @@ cli ) cli.help() -cli.version('2.1.1') +cli.version(packageJson.version) withErrorBoundary(async () => { const userArgs = process.argv.slice(2) diff --git a/test/unit/cli/info.spec.ts b/test/unit/cli/info.spec.ts index 5e4d5cff..ffe0bd83 100644 --- a/test/unit/cli/info.spec.ts +++ b/test/unit/cli/info.spec.ts @@ -1,13 +1,11 @@ -import { expect } from 'chai' -import fs from 'fs' -import { createRequire } from 'module' -import path from 'path' -import sinon from 'sinon' - -const require = createRequire(import.meta.url) -const infoCommand = require('../../../dist/src/cli/commands/info.js') as typeof import('../../../dist/src/cli/commands/info.js') -const configUtils = require('../../../dist/src/cli/utils/config.js') as typeof import('../../../dist/src/cli/utils/config.js') -const processUtils = require('../../../dist/src/cli/utils/process.js') as typeof import('../../../dist/src/cli/utils/process.js') +const { expect } = require('chai') +const fs = require('fs') +const path = require('path') +const sinon = require('sinon') + +const infoCommand = require('../../../dist/src/cli/commands/info.js') +const configUtils = require('../../../dist/src/cli/utils/config.js') +const processUtils = require('../../../dist/src/cli/utils/process.js') describe('runInfo', () => { const keysFile = path.join(process.cwd(), '.nostr', 'i2p', 'data', 'nostream.dat') diff --git a/test/unit/cli/setup.spec.ts b/test/unit/cli/setup.spec.ts index 13fb82e3..24a61030 100644 --- a/test/unit/cli/setup.spec.ts +++ b/test/unit/cli/setup.spec.ts @@ -4,7 +4,7 @@ import os from 'os' import path from 'path' import sinon from 'sinon' -const setupCommand = await import('../../../dist/src/cli/commands/setup.js') +const setupCommand: typeof import('../../../dist/src/cli/commands/setup.js') = require('../../../dist/src/cli/commands/setup.js') describe('runSetup', () => { const originalCwd = process.cwd() From a9ecb6a2739ad0f8b821c0ba9e2d98890254236d Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Sat, 25 Apr 2026 20:12:19 +0300 Subject: [PATCH 13/20] fix: update integration test scripts to use npm and npx for compatibility --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6232b31f..8179d6f5 100644 --- a/package.json +++ b/package.json @@ -88,8 +88,8 @@ "i2p:docker:compose:stop": "./scripts/stop", "docker:integration:run": "docker compose -f ./test/integration/docker-compose.yml run --rm tests", "test:cli:docker-smoke": "pnpm run cli -- stop --all && pnpm run cli -- info", - "docker:test:integration": "pnpm run docker:integration:run -- pnpm run test:integration", - "docker:cover:integration": "pnpm run docker:integration:run -- pnpm run cover:integration", + "docker:test:integration": "pnpm run docker:integration:run npm run test:integration", + "docker:cover:integration": "pnpm run docker:integration:run npx nyc --report-dir .coverage/integration npm run test:integration -- -p cover", "postdocker:integration:run": "docker compose -f ./test/integration/docker-compose.yml down", "prepack": "npm run build", "prepare": "husky install || exit 0", From 5a4543625ba412a29a4260d924a67f1e56bef8ea Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Sat, 25 Apr 2026 20:53:45 +0300 Subject: [PATCH 14/20] docs: update CONTRIBUTING.md to include unified CLI commands for development operations --- CONTRIBUTING.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d0fcfd58..f787cf55 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,6 +47,13 @@ corepack enable pnpm install ``` +Use the unified CLI for relay lifecycle and supported development operations from this source +checkout: + +``` +pnpm run cli -- --help +``` + > **Important:** Pre-commit hooks installed by Husky run linting and formatting checks on every > commit. Do **not** bypass them with `git commit --no-verify`. If a hook fails, fix the reported > issues before committing. @@ -56,7 +63,7 @@ pnpm install Start the relay (runs in the foreground until stopped with Ctrl+C): ``` -./scripts/start +pnpm run cli -- start ``` ### Development Quick Start (Standalone) @@ -149,7 +156,7 @@ cd /path/to/nostream Run unit tests: ``` -pnpm test:unit +pnpm run cli -- dev test:unit ``` Run unit tests in watch mode: @@ -223,7 +230,7 @@ DB_MAX_POOL_SIZE=2 Run the integration tests: ``` -pnpm test:integration +pnpm run cli -- dev test:integration ``` Open the integration test report: From 38c0aefea5dc98071f6732031c1ab39017188c1b Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Sat, 25 Apr 2026 20:57:53 +0300 Subject: [PATCH 15/20] feat(docker): update docker compose commands to use CLI for improved consistency --- package.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 8179d6f5..384e5578 100644 --- a/package.json +++ b/package.json @@ -77,15 +77,15 @@ "test:integration": "cucumber-js", "cover:integration": "nyc --report-dir .coverage/integration pnpm run test:integration -p cover", "export": "node --env-file-if-exists=.env -r ts-node/register src/scripts/export-events.ts", - "docker:compose:start": "./scripts/start", - "docker:compose:stop": "./scripts/stop", - "docker:compose:clean": "./scripts/clean", - "tor:docker:compose:start": "./scripts/start_with_tor", - "tor:hostname": "./scripts/print_tor_hostname", - "tor:docker:compose:stop": "./scripts/stop", - "i2p:docker:compose:start": "./scripts/start_with_i2p", - "i2p:hostname": "./scripts/print_i2p_hostname", - "i2p:docker:compose:stop": "./scripts/stop", + "docker:compose:start": "pnpm run cli -- start", + "docker:compose:stop": "pnpm run cli -- stop", + "docker:compose:clean": "pnpm run cli -- clean", + "tor:docker:compose:start": "pnpm run cli -- start --tor", + "tor:hostname": "pnpm run cli -- info --tor-hostname", + "tor:docker:compose:stop": "pnpm run cli -- stop", + "i2p:docker:compose:start": "pnpm run cli -- start --i2p", + "i2p:hostname": "pnpm run cli -- info --i2p-hostname", + "i2p:docker:compose:stop": "pnpm run cli -- stop", "docker:integration:run": "docker compose -f ./test/integration/docker-compose.yml run --rm tests", "test:cli:docker-smoke": "pnpm run cli -- stop --all && pnpm run cli -- info", "docker:test:integration": "pnpm run docker:integration:run npm run test:integration", From a612f50976281607f345512b92bce3cc56f90875 Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Sat, 25 Apr 2026 21:12:33 +0300 Subject: [PATCH 16/20] fix: update CLI commands to use pnpm for consistency --- CLI.md | 2 +- README.md | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CLI.md b/CLI.md index 8b9d0c09..d62cfda0 100644 --- a/CLI.md +++ b/CLI.md @@ -4,7 +4,7 @@ Nostream ships a unified command-line interface: ```bash nostream --help -npm run cli -- --help +pnpm run cli -- --help ``` When run with no arguments in an interactive terminal, `nostream` launches an interactive TUI. diff --git a/README.md b/README.md index af7f1261..7162d58c 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ Non-interactive CLI usage conventions: Optional global installation from a source checkout: ``` - npm i -g . + pnpm add -g . nostream --help ``` @@ -525,10 +525,10 @@ Start: Run code quality checks with Biome: ``` - npm run lint - npm run lint:fix - npm run format - npm run format:check + pnpm lint + pnpm lint:fix + pnpm format + pnpm check:format ``` ### Unit tests @@ -541,19 +541,19 @@ Open a terminal and change to the project's directory: Run unit tests with: ``` - npm run test:unit + pnpm test:unit ``` Or, run unit tests in watch mode: ``` - npm run test:unit:watch + pnpm test:unit:watch ``` To get unit test coverage run: ``` - npm run cover:unit + pnpm cover:unit ``` To see the unit tests report open `.test-reports/unit/index.html` with a browser: From 79074b84ae5a440ddfcf5ae825dd11d6288aa0bb Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Sat, 25 Apr 2026 21:17:46 +0300 Subject: [PATCH 17/20] chore: remove outdated development quick start instructions from README --- README.md | 73 ------------------------------------------------------- 1 file changed, 73 deletions(-) diff --git a/README.md b/README.md index 7162d58c..8705b869 100644 --- a/README.md +++ b/README.md @@ -500,79 +500,6 @@ To clean up the build, coverage and test reports run: ``` pnpm clean ``` -## Development Quick Start (Docker Compose) - -Install Docker Desktop following the [official guide](https://docs.docker.com/desktop/). -You may have to uninstall Docker on your machine if you installed it using a different guide. - -Clone repository and enter directory: - ``` - git clone git@github.com:Cameri/nostream.git - cd nostream - ``` - -Start: - ``` - nostream start - ``` - - This will run in the foreground of the terminal until you stop it with Ctrl+C. - -## Tests - -### Linting and formatting (Biome) - -Run code quality checks with Biome: - - ``` - pnpm lint - pnpm lint:fix - pnpm format - pnpm check:format - ``` - -### Unit tests - -Open a terminal and change to the project's directory: - ``` - cd /path/to/nostream - ``` - -Run unit tests with: - - ``` - pnpm test:unit - ``` - -Or, run unit tests in watch mode: - - ``` - pnpm test:unit:watch - ``` - -To get unit test coverage run: - - ``` - pnpm cover:unit - ``` - -To see the unit tests report open `.test-reports/unit/index.html` with a browser: - ``` - open .test-reports/unit/index.html - ``` - -To see the unit tests coverage report open `.coverage/unit/lcov-report/index.html` with a browser: - ``` - open .coverage/unit/lcov-report/index.html - ``` - -### Integration tests (Docker Compose) - -Open a terminal and change to the project's directory: - ``` - cd /path/to/nostream - ``` - ## Development & Contributing For development environment setup, testing, linting, load testing, and contribution guidelines From 08456ed23e5a9efb64371757826965b16f369762 Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Sat, 25 Apr 2026 21:41:19 +0300 Subject: [PATCH 18/20] fix: update CLI commands to use pnpm for consistency --- package.json | 10 ++++----- scripts/verify-cli-build.js | 25 ++++++++++------------- scripts/verify-index-impact.ts | 2 +- src/cli/commands/dev.ts | 12 +++++------ src/cli/commands/seed.ts | 2 +- test/unit/cli/cli.integration.spec.ts | 29 ++++++++++++--------------- 6 files changed, 37 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index 384e5578..25793dd1 100644 --- a/package.json +++ b/package.json @@ -64,9 +64,9 @@ "db:seed": "knex seed:run", "db:benchmark": "node --env-file-if-exists=.env -r ts-node/register src/scripts/benchmark-queries.ts", "db:verify-index-impact": "node --env-file-if-exists=.env -r ts-node/register scripts/verify-index-impact.ts", - "pretest:unit": "npm run build && node -e \"require('fs').mkdirSync('.test-reports/unit', {recursive: true})\"", + "pretest:unit": "pnpm run build && node -e \"require('fs').mkdirSync('.test-reports/unit', {recursive: true})\"", "test:unit": "mocha 'test/**/*.spec.ts'", - "pretest:cli": "npm run build", + "pretest:cli": "pnpm run build", "test:cli": "mocha 'test/unit/cli/**/*.spec.ts'", "test:unit:watch": "pnpm run test:unit --min --watch --watch-files src/**/*,test/**/*", "cover:unit": "nyc --report-dir .coverage/unit pnpm run test:unit", @@ -88,10 +88,10 @@ "i2p:docker:compose:stop": "pnpm run cli -- stop", "docker:integration:run": "docker compose -f ./test/integration/docker-compose.yml run --rm tests", "test:cli:docker-smoke": "pnpm run cli -- stop --all && pnpm run cli -- info", - "docker:test:integration": "pnpm run docker:integration:run npm run test:integration", - "docker:cover:integration": "pnpm run docker:integration:run npx nyc --report-dir .coverage/integration npm run test:integration -- -p cover", + "docker:test:integration": "pnpm run docker:integration:run pnpm run test:integration", + "docker:cover:integration": "pnpm run docker:integration:run pnpm exec nyc --report-dir .coverage/integration pnpm run test:integration -- -p cover", "postdocker:integration:run": "docker compose -f ./test/integration/docker-compose.yml down", - "prepack": "npm run build", + "prepack": "pnpm run build", "prepare": "husky install || exit 0", "changeset:version": "changeset version && pnpm install --lockfile-only", "changeset:publish": "changeset publish" diff --git a/scripts/verify-cli-build.js b/scripts/verify-cli-build.js index 13c7af4d..c9964ab3 100644 --- a/scripts/verify-cli-build.js +++ b/scripts/verify-cli-build.js @@ -25,15 +25,12 @@ const requiredPackedFiles = [ 'docker-compose.yml', ] -const parseNpmJsonOutput = (output) => { - const start = output.indexOf('[') - const end = output.lastIndexOf(']') - - if (start === -1 || end === -1 || end < start) { - throw new Error('No JSON payload found in npm output') +const parsePackJsonOutput = (output) => { + const start = output.search(/^\s*[\[{]/m) + if (start === -1) { + throw new Error('No JSON payload found in pack output') } - - return JSON.parse(output.slice(start, end + 1)) + return JSON.parse(output.slice(start).trim()) } const result = spawnSync('node', [binPath, '--help'], { @@ -58,14 +55,14 @@ if (!result.stdout.includes('Usage:')) { process.exit(1) } -const packResult = spawnSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { +const packResult = spawnSync('pnpm', ['pack', '--dry-run', '--json'], { cwd: path.resolve(__dirname, '..'), env: process.env, encoding: 'utf-8', }) if (packResult.status !== 0) { - console.error(`npm pack dry-run failed (exit ${packResult.status ?? 1})`) + console.error(`pnpm pack dry-run failed (exit ${packResult.status ?? 1})`) if (packResult.stdout) { process.stderr.write(packResult.stdout) } @@ -77,17 +74,17 @@ if (packResult.status !== 0) { let packed try { - packed = parseNpmJsonOutput(packResult.stdout) + packed = parsePackJsonOutput(packResult.stdout) } catch (error) { - console.error('Failed to parse npm pack --json output') + console.error('Failed to parse pnpm pack --json output') process.stderr.write(String(error)) process.exit(1) } -const files = new Set((packed[0]?.files ?? []).map((file) => file.path)) +const files = new Set((packed?.files ?? []).map((file) => file.path)) for (const requiredFile of requiredPackedFiles) { if (!files.has(requiredFile)) { - console.error(`Packed npm artifact is missing required file: ${requiredFile}`) + console.error(`Packed artifact is missing required file: ${requiredFile}`) process.exit(1) } } diff --git a/scripts/verify-index-impact.ts b/scripts/verify-index-impact.ts index 29655159..40d00352 100644 --- a/scripts/verify-index-impact.ts +++ b/scripts/verify-index-impact.ts @@ -12,7 +12,7 @@ * * Usage: * node -r ts-node/register scripts/verify-index-impact.ts [--events N] [--pubkeys N] [--runs N] - * npm run db:verify-index-impact + * pnpm run db:verify-index-impact */ import { randomBytes } from 'node:crypto' diff --git a/src/cli/commands/dev.ts b/src/cli/commands/dev.ts index 78e34807..7b4fa3d0 100644 --- a/src/cli/commands/dev.ts +++ b/src/cli/commands/dev.ts @@ -75,14 +75,14 @@ export const runDevDbReset = async (options: DevOptions): Promise => { const spinner = ora('Resetting database (rollback)...').start() - let code = await runCommand('npm', ['run', 'db:migrate:rollback', '--', '--all']) + let code = await runCommand('pnpm', ['run', 'db:migrate:rollback', '--', '--all']) if (code !== 0) { spinner.fail('Database reset failed during rollback') return code } spinner.text = 'Resetting database (migrate)...' - code = await runCommand('npm', ['run', 'db:migrate']) + code = await runCommand('pnpm', ['run', 'db:migrate']) if (code === 0) { spinner.succeed('Database reset completed') } else { @@ -94,7 +94,7 @@ export const runDevDbReset = async (options: DevOptions): Promise => { export const runDevSeedRelay = async (): Promise => { return runWithSpinner('Seeding relay data...', 'Relay seed completed', 'Relay seed failed', () => - runCommand('npm', ['run', 'db:seed']), + runCommand('pnpm', ['run', 'db:seed']), ) } @@ -119,13 +119,13 @@ export const runDevDockerClean = async (options: DevOptions): Promise => export const runDevTestUnit = async (): Promise => { return runWithSpinner('Running unit tests...', 'Unit tests completed', 'Unit tests failed', () => - runCommand('npm', ['run', 'test:unit']), + runCommand('pnpm', ['run', 'test:unit']), ) } export const runDevTestCli = async (): Promise => { return runWithSpinner('Running CLI tests...', 'CLI tests completed', 'CLI tests failed', () => - runCommand('npm', ['run', 'test:cli']), + runCommand('pnpm', ['run', 'test:cli']), ) } @@ -134,6 +134,6 @@ export const runDevTestIntegration = async (): Promise => { 'Running integration tests...', 'Integration tests completed', 'Integration tests failed', - () => runCommand('npm', ['run', 'test:integration']), + () => runCommand('pnpm', ['run', 'test:integration']), ) } diff --git a/src/cli/commands/seed.ts b/src/cli/commands/seed.ts index c8880d4b..3586476e 100644 --- a/src/cli/commands/seed.ts +++ b/src/cli/commands/seed.ts @@ -15,7 +15,7 @@ export const runSeed = async (options: SeedOptions): Promise => { const spinner = ora('Seeding relay data...').start() - const code = await runCommand('npm', ['run', 'db:seed'], { + const code = await runCommand('pnpm', ['run', 'db:seed'], { env: options.count ? { NOSTREAM_SEED_COUNT: String(options.count) } : undefined, }) diff --git a/test/unit/cli/cli.integration.spec.ts b/test/unit/cli/cli.integration.spec.ts index 79f35cb0..69462de2 100644 --- a/test/unit/cli/cli.integration.spec.ts +++ b/test/unit/cli/cli.integration.spec.ts @@ -45,9 +45,9 @@ const runCli = (args: string[], env: NodeJS.ProcessEnv = {}): Promise }) } -const runNpmCli = (args: string[], env: NodeJS.ProcessEnv = {}): Promise => { +const runPnpmCli = (args: string[], env: NodeJS.ProcessEnv = {}): Promise => { return new Promise((resolve, reject) => { - const child = spawn('npm', ['run', 'cli', '--', ...args], { + const child = spawn('pnpm', ['run', 'cli', ...args], { cwd: projectRoot, env: { ...process.env, @@ -88,15 +88,12 @@ const createShimCommand = (dir: string, name: string, scriptBody: string) => { fs.chmodSync(target, 0o755) } -const parseNpmJsonOutput = (output: string): T => { - const start = output.indexOf('[') - const end = output.lastIndexOf(']') - - if (start === -1 || end === -1 || end < start) { - throw new Error(`No JSON payload found in npm output: ${output}`) +const parsePackJsonOutput = (output: string): T => { + const start = output.search(/^\s*[\[{]/m) + if (start === -1) { + throw new Error(`No JSON payload found in pack output: ${output}`) } - - return JSON.parse(output.slice(start, end + 1)) as T + return JSON.parse(output.slice(start).trim()) as T } const runCommand = (command: string, args: string[]): Promise => { @@ -142,8 +139,8 @@ describe('cli integration (spawn)', function () { expect(result.stdout).to.include('clean') }) - it('supports npm run cli as the documented entry point', async () => { - const result = await runNpmCli(['--help']) + it('supports pnpm run cli as the documented entry point', async () => { + const result = await runPnpmCli(['--help']) expect(result.code).to.equal(0) expect(result.stdout).to.include('Usage:') @@ -161,13 +158,13 @@ describe('cli integration (spawn)', function () { }) it('packs the built CLI and runtime assets required for installation', async () => { - const result = await runCommand('npm', ['pack', '--dry-run', '--json', '--ignore-scripts']) + const result = await runCommand('pnpm', ['pack', '--dry-run', '--json']) expect(result.code).to.equal(0) - const packSummary = parseNpmJsonOutput - }>>(result.stdout) - const packedFiles = new Set(packSummary[0].files.map((file) => file.path)) + }>(result.stdout) + const packedFiles = new Set(packSummary.files.map((file) => file.path)) expect(packedFiles.has('package.json')).to.equal(true) expect(packedFiles.has('dist/src/cli/index.js')).to.equal(true) From b158df6d4463b997677f472c69f9375fdb33104a Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Sat, 25 Apr 2026 22:13:10 +0300 Subject: [PATCH 19/20] feat: introduce unified nostream CLI/TUI to replace legacy shell wrappers --- .changeset/sour-dolls-sip.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/sour-dolls-sip.md b/.changeset/sour-dolls-sip.md index b33932ed..67d18a8d 100644 --- a/.changeset/sour-dolls-sip.md +++ b/.changeset/sour-dolls-sip.md @@ -1,5 +1,5 @@ --- -"nostream": patch +"nostream": major --- -Fix CI stability by including the CLI entrypoint in Knip analysis and removing an unused dependency. +Add a brand-new unified `nostream` CLI/TUI that replaces the legacy `scripts/*` shell wrappers for lifecycle, setup, info, config, data, and development workflows. From 73575abe400ce3c5a94165ffffb28b4e8b0e7806 Mon Sep 17 00:00:00 2001 From: Mahmoud Khedr Date: Sat, 25 Apr 2026 22:21:48 +0300 Subject: [PATCH 20/20] fix: correct consistency issues in CLI/TUI after pnpm migration --- .changeset/sour-dolls-sip.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.changeset/sour-dolls-sip.md b/.changeset/sour-dolls-sip.md index 67d18a8d..586928ee 100644 --- a/.changeset/sour-dolls-sip.md +++ b/.changeset/sour-dolls-sip.md @@ -3,3 +3,6 @@ --- Add a brand-new unified `nostream` CLI/TUI that replaces the legacy `scripts/*` shell wrappers for lifecycle, setup, info, config, data, and development workflows. + +**Fixes** + - fixed some consistnacy issues after the migration from `npm` to `pnpm`