diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b35c8d9..0b4beda 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,3 +22,5 @@ jobs: node-version: ${{ matrix.node-version }} - run: npm ci - run: npm test + - run: npm run ci + - run: npm run build \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..1f45642 --- /dev/null +++ b/biome.json @@ -0,0 +1,27 @@ +{ + "formatter": { + "formatWithErrors": true + }, + "json": { + "formatter": { + "indentStyle": "space", + "indentWidth": 2 + } + }, + "javascript": { + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "quoteStyle": "single", + "arrowParentheses": "asNeeded", + "trailingComma": "none" + } + }, + "organizeImports": { + "enabled": true, + "include": [ + "src/**/*.ts" + ] + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b1549b5..cf391cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,19 +13,175 @@ "dotenv": "^16.0.3", "https-proxy-agent": "^6.2.0", "node-fetch": "^3.3.2", - "picocolors": "^1.0.0" + "picocolors": "^1.0.0", + "string-width": "^7.1.0" }, "bin": { "chatgpt-md-translator": "lib/index.js" }, "devDependencies": { + "@biomejs/biome": "1.6.4", "@types/dashdash": "^1.14.1", "@types/node": "^20.5.7", - "prettier": "^2.8.4", "tsx": "^4.7.1", "typescript": "^5.2.2" } }, + "node_modules/@biomejs/biome": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.6.4.tgz", + "integrity": "sha512-3groVd2oWsLC0ZU+XXgHSNbq31lUcOCBkCcA7sAQGBopHcmL+jmmdoWlY3S61zIh+f2mqQTQte1g6PZKb3JJjA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.6.4", + "@biomejs/cli-darwin-x64": "1.6.4", + "@biomejs/cli-linux-arm64": "1.6.4", + "@biomejs/cli-linux-arm64-musl": "1.6.4", + "@biomejs/cli-linux-x64": "1.6.4", + "@biomejs/cli-linux-x64-musl": "1.6.4", + "@biomejs/cli-win32-arm64": "1.6.4", + "@biomejs/cli-win32-x64": "1.6.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.6.4.tgz", + "integrity": "sha512-2WZef8byI9NRzGajGj5RTrroW9BxtfbP9etigW1QGAtwu/6+cLkdPOWRAs7uFtaxBNiKFYA8j/BxV5zeAo5QOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.6.4.tgz", + "integrity": "sha512-uo1zgM7jvzcoDpF6dbGizejDLCqNpUIRkCj/oEK0PB0NUw8re/cn1EnxuOLZqDpn+8G75COLQTOx8UQIBBN/Kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.6.4.tgz", + "integrity": "sha512-wAOieaMNIpLrxGc2/xNvM//CIZg7ueWy3V5A4T7gDZ3OL/Go27EKE59a+vMKsBCYmTt7jFl4yHz0TUkUbodA/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.6.4.tgz", + "integrity": "sha512-Hp8Jwt6rjj0wCcYAEN6/cfwrrPLLlGOXZ56Lei4Pt4jy39+UuPeAVFPeclrrCfxyL1wQ2xPrhd/saTHSL6DoJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.6.4.tgz", + "integrity": "sha512-qTWhuIw+/ePvOkjE9Zxf5OqSCYxtAvcTJtVmZT8YQnmY2I62JKNV2m7tf6O5ViKZUOP0mOQ6NgqHKcHH1eT8jw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.6.4.tgz", + "integrity": "sha512-wqi0hr8KAx5kBO0B+m5u8QqiYFFBJOSJVSuRqTeGWW+GYLVUtXNidykNqf1JsW6jJDpbkSp2xHKE/bTlVaG2Kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.6.4.tgz", + "integrity": "sha512-Wp3FiEeF6v6C5qMfLkHwf4YsoNHr/n0efvoC8jCKO/kX05OXaVExj+1uVQ1eGT7Pvx0XVm/TLprRO0vq/V6UzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.6.4.tgz", + "integrity": "sha512-mz183Di5hTSGP7KjNWEhivcP1wnHLGmOxEROvoFsIxMYtDhzJDad4k5gI/1JbmA0xe4n52vsgqo09tBhrMT/Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.19.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", @@ -417,6 +573,17 @@ "node": ">= 14" } }, + "node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -468,6 +635,11 @@ "node": ">=12" } }, + "node_modules/emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==" + }, "node_modules/esbuild": { "version": "0.19.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", @@ -553,6 +725,17 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-east-asian-width": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", + "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-tsconfig": { "version": "4.7.3", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.3.tgz", @@ -622,28 +805,43 @@ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" }, - "node_modules/prettier": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.4.tgz", - "integrity": "sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw==", + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, - "bin": { - "prettier": "bin-prettier.js" + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/string-width": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.1.0.tgz", + "integrity": "sha512-SEIJCWiX7Kg4c129n48aDRwLbFb2LJmXXFrWBG4NGaRtMQ3myKPKbwrD1BKqQn74oCoNMBVrfDEr5M9YxCsrkw==", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10.13.0" + "node": ">=18" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/tsx": { @@ -688,6 +886,78 @@ } }, "dependencies": { + "@biomejs/biome": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.6.4.tgz", + "integrity": "sha512-3groVd2oWsLC0ZU+XXgHSNbq31lUcOCBkCcA7sAQGBopHcmL+jmmdoWlY3S61zIh+f2mqQTQte1g6PZKb3JJjA==", + "dev": true, + "requires": { + "@biomejs/cli-darwin-arm64": "1.6.4", + "@biomejs/cli-darwin-x64": "1.6.4", + "@biomejs/cli-linux-arm64": "1.6.4", + "@biomejs/cli-linux-arm64-musl": "1.6.4", + "@biomejs/cli-linux-x64": "1.6.4", + "@biomejs/cli-linux-x64-musl": "1.6.4", + "@biomejs/cli-win32-arm64": "1.6.4", + "@biomejs/cli-win32-x64": "1.6.4" + } + }, + "@biomejs/cli-darwin-arm64": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.6.4.tgz", + "integrity": "sha512-2WZef8byI9NRzGajGj5RTrroW9BxtfbP9etigW1QGAtwu/6+cLkdPOWRAs7uFtaxBNiKFYA8j/BxV5zeAo5QOQ==", + "dev": true, + "optional": true + }, + "@biomejs/cli-darwin-x64": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.6.4.tgz", + "integrity": "sha512-uo1zgM7jvzcoDpF6dbGizejDLCqNpUIRkCj/oEK0PB0NUw8re/cn1EnxuOLZqDpn+8G75COLQTOx8UQIBBN/Kg==", + "dev": true, + "optional": true + }, + "@biomejs/cli-linux-arm64": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.6.4.tgz", + "integrity": "sha512-wAOieaMNIpLrxGc2/xNvM//CIZg7ueWy3V5A4T7gDZ3OL/Go27EKE59a+vMKsBCYmTt7jFl4yHz0TUkUbodA/w==", + "dev": true, + "optional": true + }, + "@biomejs/cli-linux-arm64-musl": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.6.4.tgz", + "integrity": "sha512-Hp8Jwt6rjj0wCcYAEN6/cfwrrPLLlGOXZ56Lei4Pt4jy39+UuPeAVFPeclrrCfxyL1wQ2xPrhd/saTHSL6DoJg==", + "dev": true, + "optional": true + }, + "@biomejs/cli-linux-x64": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.6.4.tgz", + "integrity": "sha512-qTWhuIw+/ePvOkjE9Zxf5OqSCYxtAvcTJtVmZT8YQnmY2I62JKNV2m7tf6O5ViKZUOP0mOQ6NgqHKcHH1eT8jw==", + "dev": true, + "optional": true + }, + "@biomejs/cli-linux-x64-musl": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.6.4.tgz", + "integrity": "sha512-wqi0hr8KAx5kBO0B+m5u8QqiYFFBJOSJVSuRqTeGWW+GYLVUtXNidykNqf1JsW6jJDpbkSp2xHKE/bTlVaG2Kg==", + "dev": true, + "optional": true + }, + "@biomejs/cli-win32-arm64": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.6.4.tgz", + "integrity": "sha512-Wp3FiEeF6v6C5qMfLkHwf4YsoNHr/n0efvoC8jCKO/kX05OXaVExj+1uVQ1eGT7Pvx0XVm/TLprRO0vq/V6UzA==", + "dev": true, + "optional": true + }, + "@biomejs/cli-win32-x64": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.6.4.tgz", + "integrity": "sha512-mz183Di5hTSGP7KjNWEhivcP1wnHLGmOxEROvoFsIxMYtDhzJDad4k5gI/1JbmA0xe4n52vsgqo09tBhrMT/Zg==", + "dev": true, + "optional": true + }, "@esbuild/aix-ppc64": { "version": "0.19.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", @@ -869,6 +1139,11 @@ "debug": "^4.3.4" } }, + "ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==" + }, "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -900,6 +1175,11 @@ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==" }, + "emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==" + }, "esbuild": { "version": "0.19.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", @@ -955,6 +1235,11 @@ "dev": true, "optional": true }, + "get-east-asian-width": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", + "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==" + }, "get-tsconfig": { "version": "4.7.3", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.3.tgz", @@ -998,18 +1283,30 @@ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" }, - "prettier": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.4.tgz", - "integrity": "sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw==", - "dev": true - }, "resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true }, + "string-width": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.1.0.tgz", + "integrity": "sha512-SEIJCWiX7Kg4c129n48aDRwLbFb2LJmXXFrWBG4NGaRtMQ3myKPKbwrD1BKqQn74oCoNMBVrfDEr5M9YxCsrkw==", + "requires": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + } + }, + "strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "requires": { + "ansi-regex": "^6.0.1" + } + }, "tsx": { "version": "4.7.1", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.1.tgz", diff --git a/package.json b/package.json index 4df0722..57b86c5 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ }, "scripts": { "build": "tsc", - "test": "tsx --test src/**/*.test.ts" + "test": "tsx --test src/**/*.test.ts", + "format": "biome format --write ./src ./biome.json", + "lint": "biome lint ./src", + "ci": "biome ci ./src" }, "keywords": [ "cli", @@ -28,12 +31,13 @@ "dotenv": "^16.0.3", "https-proxy-agent": "^6.2.0", "node-fetch": "^3.3.2", - "picocolors": "^1.0.0" + "picocolors": "^1.0.0", + "string-width": "^7.1.0" }, "devDependencies": { + "@biomejs/biome": "1.6.4", "@types/dashdash": "^1.14.1", "@types/node": "^20.5.7", - "prettier": "^2.8.4", "tsx": "^4.7.1", "typescript": "^5.2.2" }, @@ -42,4 +46,4 @@ "trailingComma": "none", "arrowParens": "avoid" } -} \ No newline at end of file +} diff --git a/src/api.ts b/src/api.ts index 604787b..a0b7fda 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,9 +1,10 @@ +import type { Readable } from 'node:stream'; import { HttpsProxyAgent } from 'https-proxy-agent'; -import fetch, { Response } from 'node-fetch'; -import { Readable } from 'node:stream'; -import { Config } from './loadConfig.js'; -import { ErrorStatus, SettledStatus, Status } from './status.js'; +import fetch, { AbortError, type Response } from 'node-fetch'; +import type { Config } from './loadConfig.js'; +import type { ErrorStatus, SettledStatus, Status } from './status.js'; import combineAbortSignals from './utils/combineAbortSignals.js'; +import { isMessageError } from './utils/error-utils.js'; import limitCallRate from './utils/limitCallRate.js'; import readlineFromStream from './utils/readlineFromStream.js'; @@ -92,15 +93,18 @@ const configureApiCaller = (options: ConfigureApiOptions) => { stream: true }) }); - } catch (err: any) { - if (err?.name === 'AbortError') { + } catch (err: unknown) { + if (err instanceof AbortError) { abortedByCaller = true; onStatus({ status: 'aborted' }); return { status: 'aborted' }; - } else { - onStatus({ status: 'error', message: err.message }); - return { status: 'error', message: err.message }; } + const status: ErrorStatus = { + status: 'error', + message: isMessageError(err) ? err.message : 'network error' + }; + onStatus(status); + return status; } const retry = () => { @@ -108,10 +112,10 @@ const configureApiCaller = (options: ConfigureApiOptions) => { return callApi(text, config, onStatus, signal, maxRetry - 1); }; - let intervalId: NodeJS.Timeout | undefined; + if (!response) throw new Error(); - if (response!.status >= 400) { - const res = (await response!.json()) as ErrorResponse; + if (response.status >= 400) { + const res = (await response.json()) as ErrorResponse; if ( res.error.message.match(/You can retry/) && maxRetry > 0 && @@ -122,50 +126,47 @@ const configureApiCaller = (options: ConfigureApiOptions) => { } onStatus({ status: 'error', message: res.error.message }); return { status: 'error', message: res.error.message }; - } else { - const stream = response!.body! as Readable; - let resultText = ''; - let lastReceiveTime = new Date().getTime(); + } + const stream = response.body as Readable; + let resultText = ''; + let lastReceiveTime = new Date().getTime(); - intervalId = setInterval(() => { - // If the data transmission is stopped for 30 seconds, we retry. - if (lastReceiveTime + 30000 < new Date().getTime()) { - abortController.abort('Server stopped responding'); - } - }, 1000); + const intervalId = setInterval(() => { + // If the data transmission is stopped for 30 seconds, we retry. + if (lastReceiveTime + 30000 < new Date().getTime()) { + abortController.abort('Server stopped responding'); + } + }, 1000); - try { - for await (const line of readlineFromStream(stream)) { - lastReceiveTime = new Date().getTime(); - if (line.trim().length === 0) continue; - if (line.includes('[DONE]')) break; - const res = JSON.parse(line.split(': ', 2)[1]) as ApiStreamResponse; - if (res.choices[0]?.finish_reason === 'length') { - onStatus({ status: 'error', message: 'reduce the length.' }); - return { status: 'error', message: 'reduce the length.' }; - } - const content = res.choices[0].delta.content ?? ''; - if (content.length) - onStatus({ status: 'pending', lastToken: content }); - resultText += content; - } - } catch (err: any) { - if (err?.name === 'AbortError' && maxRetry > 0 && !abortedByCaller) { - console.error(err.reason, '\n\n'); - return retry(); + try { + for await (const line of readlineFromStream(stream)) { + lastReceiveTime = new Date().getTime(); + if (line.trim().length === 0) continue; + if (line.includes('[DONE]')) break; + const res = JSON.parse(line.split(': ', 2)[1]) as ApiStreamResponse; + if (res.choices[0]?.finish_reason === 'length') { + onStatus({ status: 'error', message: 'reduce the length.' }); + return { status: 'error', message: 'reduce the length.' }; } - const status: ErrorStatus = { - status: 'error', - message: err?.name === 'AbortError' ? 'aborted' : 'stream read error' - }; - onStatus(status); - return status; - } finally { - intervalId && clearInterval(intervalId); + const content = res.choices[0].delta.content ?? ''; + if (content.length) onStatus({ status: 'pending', lastToken: content }); + resultText += content; + } + } catch (err: unknown) { + if (err instanceof AbortError && maxRetry > 0 && !abortedByCaller) { + return retry(); } - onStatus({ status: 'done', translation: resultText }); - return { status: 'done', translation: resultText }; + const status: ErrorStatus = { + status: 'error', + message: err instanceof AbortError ? 'aborted' : 'stream read error' + }; + onStatus(status); + return status; + } finally { + intervalId && clearInterval(intervalId); } + onStatus({ status: 'done', translation: resultText }); + return { status: 'done', translation: resultText }; }; return limitCallRate(callApi, rateLimit); }; diff --git a/src/index.ts b/src/index.ts index 6181cb9..a1dca83 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,23 @@ #!/usr/bin/env node -import dashdash from 'dashdash'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; +import dashdash from 'dashdash'; import pc from 'picocolors'; import configureApiCaller from './api.js'; +import { loadConfig } from './loadConfig.js'; +import { type DoneStatus, type Status, statusToText } from './status.js'; +import { translateMultiple } from './translate.js'; import { checkFileWritable, readTextFile, resolveOutFilePath } from './utils/fs-utils.js'; -import { loadConfig } from './loadConfig.js'; import { replaceCodeBlocks, restoreCodeBlocks, splitStringAtBlankLines } from './utils/md-utils.js'; -import { DoneStatus, Status, statusToText } from './status.js'; -import { translateMultiple } from './translate.js'; const options = [ { names: ['model', 'm'], type: 'string', help: 'Model to use.' }, @@ -49,7 +49,8 @@ const main = async () => { } const { config, warnings } = await loadConfig(args); - warnings.forEach(w => console.error(pc.bgYellow('Warn'), pc.yellow(w))); + for (const warning of warnings) + console.error(pc.bgYellow('Warn'), pc.yellow(warning)); const file = args._args[0]; const filePath = path.resolve(config.baseDir ?? process.cwd(), file); @@ -64,7 +65,10 @@ const main = async () => { markdown, config.codeBlockPreservationLines ); - const fragments = splitStringAtBlankLines(replacedMd, config.fragmentSize)!; + const fragments = splitStringAtBlankLines( + replacedMd, + config.fragmentSize + ) ?? [replacedMd]; let status: Status = { status: 'pending', lastToken: '' }; @@ -73,7 +77,7 @@ const main = async () => { console.log( pc.bold('Model:'), - config.model + ',', + config.model, pc.bold('Temperature:'), String(config.temperature), '\n' @@ -105,7 +109,7 @@ const main = async () => { if (result.status === 'error') throw new Error(result.message); const translatedText = (result as DoneStatus).translation; - const finalResult = restoreCodeBlocks(translatedText, codeBlocks) + '\n'; + const finalResult = `${restoreCodeBlocks(translatedText, codeBlocks)}\n`; const elapsedTime = Date.now() - startTime; const formatTime = (msec: number) => msec < 60000 diff --git a/src/loadConfig.ts b/src/loadConfig.ts index 85a1781..b9a126d 100644 --- a/src/loadConfig.ts +++ b/src/loadConfig.ts @@ -1,7 +1,8 @@ -import { parse } from 'dotenv'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; +import { parse } from 'dotenv'; +import { isNodeException } from './utils/error-utils.js'; import { readTextFile } from './utils/fs-utils.js'; const homeDir = os.homedir(); @@ -28,7 +29,8 @@ const findFile = async (paths: string[]) => { await fs.access(path); return path; } catch (e) { - continue; + if (isNodeException(e) && e.code === 'ENOENT') continue; + throw e; // Permission denied or other error } } return null; @@ -61,30 +63,31 @@ const resolveModelShorthand = (model: string): string => { }; export const loadConfig = async (args: { - [key: string]: any; + [key: string]: unknown; }): Promise<{ config: Config; warnings: string[] }> => { const warnings: string[] = []; const configPath = await findConfigFile(); if (!configPath) throw new Error('Config file not found.'); const conf = parse(await readTextFile(configPath)); - if (!conf.OPENAI_API_KEY) + if (!conf.OPENAI_API_KEY) { throw new Error('OPENAI_API_KEY is not set in config file.'); + } const promptPath = await findPromptFile(); if (!promptPath) throw new Error('Prompt file not found.'); - const toNum = (input: any) => { + const toNum = (input: unknown) => { if (input === undefined || input === null) return undefined; const num = Number(input); - return isNaN(num) ? undefined : num; + return Number.isNaN(num) ? undefined : num; }; const outSuffix: string | null = conf.OUT_SUFFIX?.length > 0 ? conf.OUT_SUFFIX - : args.out_suffix?.length > 0 - ? args.out_suffix - : null; + : (args.out_suffix as string).length > 0 + ? (args.out_suffix as string) + : null; if (outSuffix) { warnings.push('OUT_SUFFIX is deprecated. Use OUTPUT_FILE_PATTERN instead.'); } @@ -94,19 +97,22 @@ export const loadConfig = async (args: { conf.API_ENDPOINT ?? 'https://api.openai.com/v1/chat/completions', apiKey: conf.OPENAI_API_KEY, prompt: await readTextFile(promptPath), - model: resolveModelShorthand(args.model ?? conf.MODEL_NAME ?? '3'), + model: resolveModelShorthand( + (args.model as string) ?? conf.MODEL_NAME ?? '3' + ), baseDir: conf.BASE_DIR ?? null, apiCallInterval: toNum(args.interval) ?? toNum(conf.API_CALL_INTERVAL) ?? 0, - quiet: args.quiet ?? process.stdout.isTTY === false, + quiet: + (args.quiet as boolean | undefined) ?? process.stdout.isTTY === false, fragmentSize: toNum(args.fragment_size) ?? toNum(conf.FRAGMENT_TOKEN_SIZE) ?? 2048, temperature: toNum(args.temperature) ?? toNum(conf.TEMPERATURE) ?? 0.1, codeBlockPreservationLines: toNum(conf.CODE_BLOCK_PRESERVATION_LINES) ?? 5, - out: args.out?.length > 0 ? args.out : null, + out: (args.out as string)?.length > 0 ? (args.out as string) : null, outputFilePattern: (conf.OUTPUT_FILE_PATTERN?.length > 0 ? conf.OUTPUT_FILE_PATTERN - : null) ?? (outSuffix ? '{main}' + outSuffix : null), + : null) ?? (outSuffix ? `{main}${outSuffix}` : null), httpsProxy: conf.HTTPS_PROXY ?? process.env.HTTPS_PROXY }; diff --git a/src/status.ts b/src/status.ts index 77022ae..8b51c2c 100644 --- a/src/status.ts +++ b/src/status.ts @@ -17,9 +17,11 @@ export type Status = | AbortedStatus; export const truncateStr = (str: string, maxWidth: number): string => { - if (maxWidth === Infinity) return str; - while (stringWidth(str) > maxWidth) str = str.slice(0, -1); - return str; + if (maxWidth === Number.POSITIVE_INFINITY) return str; + let truncatedStr = str; + while (stringWidth(truncatedStr) > maxWidth) + truncatedStr = truncatedStr.slice(0, -1); + return truncatedStr; }; export const statusToText = (status: Status): string => { @@ -31,7 +33,7 @@ export const statusToText = (status: Status): string => { return `⚡${tok}`; } case 'split': { - return '[' + status.members.map(statusToText).join(', ') + ']'; + return `[${status.members.map(statusToText).join(', ')}]`; } case 'done': return '✅'; diff --git a/src/translate.ts b/src/translate.ts index 0cb671d..164ebb6 100644 --- a/src/translate.ts +++ b/src/translate.ts @@ -1,9 +1,9 @@ -import { ApiCaller } from './api.js'; -import { Config } from './loadConfig.js'; +import type { ApiCaller } from './api.js'; +import type { Config } from './loadConfig.js'; import { - DoneStatus, - SettledStatus, - Status, + type DoneStatus, + type SettledStatus, + type Status, extractErrorsFromStatus } from './status.js'; import combineAbortSignals from './utils/combineAbortSignals.js'; @@ -74,14 +74,7 @@ export const translateMultiple = async ( }) ); const okay = members.every(m => m.status === 'done'); - if (okay) { - const translation = members - .map(m => (m as DoneStatus).translation) - .join('\n\n'); - const lastStatus: Status = { status: 'done', translation }; - onStatus(lastStatus); - return lastStatus; - } else { + if (!okay) { const lastStatus: Status = { status: 'error', message: members.flatMap(extractErrorsFromStatus).join('\n') @@ -89,4 +82,11 @@ export const translateMultiple = async ( onStatus(lastStatus); return lastStatus; } + + const translation = members + .map(m => (m as DoneStatus).translation) + .join('\n\n'); + const lastStatus: Status = { status: 'done', translation }; + onStatus(lastStatus); + return lastStatus; }; diff --git a/src/utils/error-utils.ts b/src/utils/error-utils.ts new file mode 100644 index 0000000..45ed53f --- /dev/null +++ b/src/utils/error-utils.ts @@ -0,0 +1,13 @@ +import typeUtils from 'node:util/types'; + +export const isNodeException = ( + error: unknown +): error is NodeJS.ErrnoException => { + return typeUtils.isNativeError(error); +}; + +export const isMessageError = ( + error: unknown +): error is { message: string } => { + return error !== null && typeof error === 'object' && 'message' in error; +}; diff --git a/src/utils/fs-utils.ts b/src/utils/fs-utils.ts index 9586e72..ad3ad48 100644 --- a/src/utils/fs-utils.ts +++ b/src/utils/fs-utils.ts @@ -1,18 +1,20 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; +import { isNodeException } from './error-utils'; // We use this to output a bit frindlier error export const readTextFile = async (filePath: string): Promise => { try { return await fs.readFile(filePath, 'utf-8'); - } catch (e: any) { + } catch (e) { + if (!isNodeException(e)) throw e; switch (e.code) { case 'EISDIR': - throw new Error('The specified path is a directory: ' + filePath); + throw new Error(`The specified path is a directory: ${filePath}`); case 'ENOENT': - throw new Error('File not found: ' + filePath); + throw new Error(`File not found: ${filePath}`); case 'EACCES': - throw new Error('Permission denied: ' + filePath); + throw new Error(`Permission denied: ${filePath}`); default: throw e; } @@ -24,25 +26,27 @@ export const checkFileWritable = async (filePath: string): Promise => { await fs.access(filePath, fs.constants.F_OK | fs.constants.W_OK); // The file exists but can be overwritten return; - } catch (error: any) { - if (error.code === 'ENOENT') { + } catch (fileError) { + if (!isNodeException(fileError)) throw fileError; + if (fileError.code === 'ENOENT') { // The file does not exist, check if directory is writable const dirPath = path.dirname(filePath); try { await fs.access(dirPath, fs.constants.F_OK | fs.constants.W_OK); // Directory exists and is writable return; - } catch (dirError: any) { + } catch (dirError) { + if (!isNodeException(dirError)) throw dirError; if (dirError.code === 'ENOENT') { // Directory does not exist - throw new Error('Directory does not exist: ' + dirPath); + throw new Error(`Directory does not exist: ${dirPath}`); } // Directory exists but is not writable, or other errors - throw new Error('Directory is not writable: ' + dirPath); + throw new Error(`Directory is not writable: ${dirPath}`); } } // File exists but is not writable, or other errors - throw new Error('File is not writable: ' + filePath); + throw new Error(`File is not writable: ${filePath}`); } }; diff --git a/src/utils/limitCallRate.ts b/src/utils/limitCallRate.ts index 25dbd84..f1b0ab1 100644 --- a/src/utils/limitCallRate.ts +++ b/src/utils/limitCallRate.ts @@ -2,7 +2,7 @@ * Takes an async function and returns a new function * that can only be started once per interval. */ -const limitCallRate =

( +const limitCallRate =

( func: (...args: P) => Promise, interval: number ): ((...args: P) => Promise) => { @@ -21,6 +21,7 @@ const limitCallRate =

( return; } processing = true; + // biome-ignore lint/style/noNonNullAssertion: const item = queue.shift()!; func(...item.args).then(item.resolve, item.reject); setTimeout(processQueue, interval * 1000); diff --git a/src/utils/md-utils.test.ts b/src/utils/md-utils.test.ts index 6c9dfd8..f18d875 100644 --- a/src/utils/md-utils.test.ts +++ b/src/utils/md-utils.test.ts @@ -1,5 +1,5 @@ -import { test } from 'node:test'; import assert from 'node:assert'; +import { test } from 'node:test'; import * as md from './md-utils.js'; const input1 = diff --git a/src/utils/md-utils.ts b/src/utils/md-utils.ts index 935098e..4c407d3 100644 --- a/src/utils/md-utils.ts +++ b/src/utils/md-utils.ts @@ -24,7 +24,8 @@ export const replaceCodeBlocks = ( const id = crypto.randomBytes(8).toString('hex'); codeBlocks[id] = content; return `${lines[0]}\n${indent}(((((${id})))))\n${indent}\`\`\``; - } else return match; + } + return match; }); return { output, codeBlocks }; }; @@ -54,14 +55,14 @@ export const restoreCodeBlocks = ( */ export const splitStringAtBlankLines = ( input: string, - fragmentLength: number = 2048 + fragmentLength = 2048 ): string[] | null => { const lines = input.split('\n'); let inCodeBlock = false; let currentFragment: string[] = []; - let fragments: string[] = []; - let nearstToHalfDiff: number = Infinity; - let nearstToHalfIndex: number = -1; + const fragments: string[] = []; + let nearstToHalfDiff: number = Number.POSITIVE_INFINITY; + let nearstToHalfIndex = -1; for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('```')) inCodeBlock = !inCodeBlock; @@ -90,8 +91,8 @@ export const splitStringAtBlankLines = ( fragments.push(lines.slice(0, nearstToHalfIndex).join('\n')); fragments.push(lines.slice(nearstToHalfIndex).join('\n')); return fragments; - } else { - fragments.push(currentFragment.join('\n')); - return fragments; } + + fragments.push(currentFragment.join('\n')); + return fragments; }; diff --git a/src/utils/readlineFromStream.ts b/src/utils/readlineFromStream.ts index 81ad6cf..e0fb51a 100644 --- a/src/utils/readlineFromStream.ts +++ b/src/utils/readlineFromStream.ts @@ -1,11 +1,13 @@ -import { Readable } from 'node:stream'; +import type { Readable } from 'node:stream'; export default async function* readlineFromStream(stream: Readable) { let remaining = ''; for await (const chunk of stream) { remaining += chunk; - let eolIndex; - while ((eolIndex = remaining.indexOf('\n')) >= 0) { + let eolIndex: number; + while (true) { + eolIndex = remaining.indexOf('\n'); + if (eolIndex < 0) break; const line = remaining.slice(0, eolIndex); remaining = remaining.slice(eolIndex + 1); yield line;