From a8c4f01c3a49e615abd8db5deef8a2d1e603e732 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 11 Jul 2026 18:26:25 +0900 Subject: [PATCH 01/11] fix(deps): override vulnerable transitive packages (#8) --- .gitignore | 2 - bun.lock | 113 ++++++++++++++++++++++++++++++++++++++++---- package.json | 13 ++++- tests/ping.test.mjs | 27 +++++++++++ 4 files changed, 141 insertions(+), 14 deletions(-) create mode 100644 tests/ping.test.mjs diff --git a/.gitignore b/.gitignore index 0650656..77de9d0 100644 --- a/.gitignore +++ b/.gitignore @@ -59,8 +59,6 @@ test-results/ .cursor/ .roo/ -# Test code -/tests/ /site/.generated/ /site/models/ /site/public/llms.txt diff --git a/bun.lock b/bun.lock index 24b78df..b3cc370 100644 --- a/bun.lock +++ b/bun.lock @@ -5,23 +5,32 @@ "": { "name": "@bytonylee/free-router", "dependencies": { - "ink": "^7.0.1", - "react": "^19.2.5", + "ink": "^7.0.3", + "react": "^19.2.6", }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/node": "^25.6.0", + "@types/node": "^25.8.0", "@types/react": "^19.2.14", - "eslint": "^10.2.1", - "globals": "^17.5.0", + "c8": "11.0.0", + "eslint": "^10.4.0", + "globals": "^17.6.0", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.1", + "typescript-eslint": "^8.59.3", }, }, }, + "overrides": { + "brace-expansion": "5.0.7", + "flatted": "3.4.2", + "picomatch": "4.0.5", + "ws": "8.21.0", + }, "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], @@ -46,10 +55,20 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], @@ -92,7 +111,9 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], + + "c8": ["c8@11.0.0", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.1", "@istanbuljs/schema": "^0.1.3", "find-up": "^5.0.0", "foreground-child": "^3.1.1", "istanbul-lib-coverage": "^3.2.0", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.1.6", "test-exclude": "^8.0.0", "v8-to-istanbul": "^9.0.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "monocart-coverage-reports": "^2" }, "optionalPeers": ["monocart-coverage-reports"], "bin": { "c8": "bin/c8.js" } }, "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -102,8 +123,16 @@ "cli-truncate": ["cli-truncate@6.0.0", "", { "dependencies": { "slice-ansi": "^9.0.0", "string-width": "^8.2.0" } }, "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -114,10 +143,14 @@ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="], @@ -150,14 +183,24 @@ "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], - "flatted": ["flatted@3.4.1", "", {}, "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ=="], + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -176,6 +219,12 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -188,10 +237,16 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -210,7 +265,9 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -220,6 +277,8 @@ "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -240,10 +299,14 @@ "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], + "test-exclude": ["test-exclude@8.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^13.0.6", "minimatch": "^10.2.2" } }, "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], @@ -260,6 +323,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], @@ -268,7 +333,13 @@ "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], - "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -278,6 +349,28 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + + "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/package.json b/package.json index 6ff7a9b..89838b9 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,14 @@ "packageManager": "bun@1.3.5", "scripts": { "build": "tsc -p tsconfig.json && cp data/model-rankings.json data/model-support.json dist/", + "coverage": "npm run build && c8 --100 --reporter=json-summary --reports-dir=coverage --include=tests/ping.test.mjs --exclude=node_modules/** node --test tests/*.test.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "eslint src --max-warnings=0", "lint:fix": "eslint src --fix", "models:sync": "npx tsx scripts/update-models.ts", "models:sync:apply": "npx tsx scripts/update-models.ts --apply", - "prepack": "npm run build" + "prepack": "npm run build", + "test": "npm run build && node --test tests/*.test.mjs" }, "engines": { "node": ">=22.0.0" @@ -50,10 +52,17 @@ "ink": "^7.0.3", "react": "^19.2.6" }, + "overrides": { + "brace-expansion": "5.0.7", + "flatted": "3.4.2", + "picomatch": "4.0.5", + "ws": "8.21.0" + }, "devDependencies": { - "@types/react": "^19.2.14", "@eslint/js": "^10.0.1", "@types/node": "^25.8.0", + "@types/react": "^19.2.14", + "c8": "11.0.0", "eslint": "^10.4.0", "globals": "^17.6.0", "typescript": "^6.0.3", diff --git a/tests/ping.test.mjs b/tests/ping.test.mjs new file mode 100644 index 0000000..b51c23b --- /dev/null +++ b/tests/ping.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { destroyAgents, stopPingLoop } from "../dist/lib/ping.js"; + +test("stopPingLoop clears an active timer and flips the running flag", () => { + const timer = setTimeout(assert.fail, 60_000, "cleared timer should not fire"); + const ref = { running: true, timer }; + + stopPingLoop(ref); + + assert.equal(ref.running, false); +}); + +test("stopPingLoop accepts missing loop refs", () => { + assert.doesNotThrow(() => { + stopPingLoop(null); + stopPingLoop(undefined); + }); +}); + +test("destroyAgents is idempotent for empty agent pools", () => { + assert.doesNotThrow(() => { + destroyAgents(); + destroyAgents(); + }); +}); From 20b3138eac73c484c4e8572dd64bd4c67c65edf8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:28:30 +0000 Subject: [PATCH 02/11] build(deps-dev): bump @types/node from 25.9.5 to 26.1.1 Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.9.5 to 26.1.1. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 26.1.1 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 89838b9..8d12615 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/node": "^25.8.0", + "@types/node": "^26.1.1", "@types/react": "^19.2.14", "c8": "11.0.0", "eslint": "^10.4.0", From 285429afd8076dde363c476500e93824e9bdc5ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 11 Jul 2026 20:58:15 +0900 Subject: [PATCH 03/11] ci: pin upload-artifact workflow action (#9) * ci: pin upload-artifact workflow action * ci: document upload-artifact tag pin --- .github/workflows/model-catalog-sync.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/model-catalog-sync.yml b/.github/workflows/model-catalog-sync.yml index c7025c4..74b764c 100644 --- a/.github/workflows/model-catalog-sync.yml +++ b/.github/workflows/model-catalog-sync.yml @@ -77,7 +77,8 @@ jobs: - name: Upload sync report if: always() - uses: actions/upload-artifact@v4 + # v7 tag resolves to 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a. + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: model-catalog-sync-report path: .tmp/model-sync-report.json From ac64eb273d4cad154cb59d151d9f0b3b8352a30b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 12 Jul 2026 17:53:48 +0900 Subject: [PATCH 04/11] ci: pin github-script workflow action --- .github/workflows/model-catalog-sync.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/model-catalog-sync.yml b/.github/workflows/model-catalog-sync.yml index 74b764c..a0e1a06 100644 --- a/.github/workflows/model-catalog-sync.yml +++ b/.github/workflows/model-catalog-sync.yml @@ -114,7 +114,7 @@ jobs: - name: Add needs-tier-review label if: steps.cpr.outputs.pull-request-number != '' && steps.report.outputs.unresolved != '0' - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | await github.rest.issues.addLabels({ From c4a1528ead0fa622c28f3527ae6b7f0fda7a3e96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 12 Jul 2026 21:06:43 +0900 Subject: [PATCH 05/11] fix(deps): refresh bun lockfile for Node 26 types --- bun.lock | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index b3cc370..196d712 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "@bytonylee/free-router", @@ -10,7 +9,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/node": "^25.8.0", + "@types/node": "^26.1.1", "@types/react": "^19.2.14", "c8": "11.0.0", "eslint": "^10.4.0", @@ -71,7 +70,7 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -319,7 +318,7 @@ "typescript-eslint": ["typescript-eslint@8.59.3", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.3", "@typescript-eslint/parser": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg=="], - "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], From 3ae807c3858b41c1bf50d7553e55691c6ffbf1fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 13:37:15 +0900 Subject: [PATCH 06/11] chore: refresh required checks From a58f9cf5b2c72b8057c5f2c9979977e9a8b96d34 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 13:39:41 +0900 Subject: [PATCH 07/11] chore: refresh required checks From 612470d99c30ee6ba1fb1371e6b95da8c9c828f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 16:43:16 +0900 Subject: [PATCH 08/11] fix(security): fix free-router CI checks (#13) * fix free-router security and CI checks * fix: make build data copy cross-platform * chore: refresh required checks * Fix production coverage gate and Bun dependabot * Emit full coverage report for changed-line gate * Cover changed source files in coverage gate --- .github/dependabot.yml | 6 +- .github/workflows/ci.yml | 3 + .gitignore | 1 + package.json | 4 +- scripts/copy-dist-data.mjs | 10 + src/lib/utils.ts | 8 + tests/copy-dist-data.test.mjs | 18 ++ tests/utils.test.mjs | 535 ++++++++++++++++++++++++++++++++++ tsconfig.json | 2 +- 9 files changed, 583 insertions(+), 4 deletions(-) create mode 100644 scripts/copy-dist-data.mjs create mode 100644 tests/copy-dist-data.test.mjs create mode 100644 tests/utils.test.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 39240b5..5b4574e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,10 +1,14 @@ version: 2 updates: - - package-ecosystem: npm + - package-ecosystem: bun directory: / schedule: interval: weekly open-pull-requests-limit: 10 + ignore: + - dependency-name: typescript + update-types: + - version-update:semver-major - package-ecosystem: github-actions directory: / diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7dee53b..1effd6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,9 @@ jobs: - name: Lint run: npm run lint + - name: Test with coverage + run: npm run coverage + - name: Typecheck run: npm run typecheck diff --git a/.gitignore b/.gitignore index 77de9d0..39a2802 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Cache artifacts cache/ .cache/ +.codegraph/ .astro/ dist/ *.tsbuildinfo diff --git a/package.json b/package.json index 8d12615..42dc84e 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "type": "module", "packageManager": "bun@1.3.5", "scripts": { - "build": "tsc -p tsconfig.json && cp data/model-rankings.json data/model-support.json dist/", - "coverage": "npm run build && c8 --100 --reporter=json-summary --reports-dir=coverage --include=tests/ping.test.mjs --exclude=node_modules/** node --test tests/*.test.mjs", + "build": "tsc -p tsconfig.json && node scripts/copy-dist-data.mjs", + "coverage": "npm run build && c8 --100 --reporter=json --reporter=json-summary --reports-dir=coverage --include=dist/lib/utils.js --include=scripts/copy-dist-data.mjs --exclude=tests/** --exclude=node_modules/** node --test tests/*.test.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "eslint src --max-warnings=0", "lint:fix": "eslint src --fix", diff --git a/scripts/copy-dist-data.mjs b/scripts/copy-dist-data.mjs new file mode 100644 index 0000000..6caf5f6 --- /dev/null +++ b/scripts/copy-dist-data.mjs @@ -0,0 +1,10 @@ +import { copyFile, mkdir } from "node:fs/promises"; + +const distDataFiles = ["model-rankings.json", "model-support.json"]; + +await mkdir("dist", { recursive: true }); +await Promise.all( + distDataFiles.map((dataFileName) => + copyFile(`data/${dataFileName}`, `dist/${dataFileName}`), + ), +); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bb5bea8..ca5eb78 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -122,6 +122,7 @@ function ensureMetrics(model: Model): ModelMetrics | null { if (!hasValidMetrics(model)) { model._metrics = recomputeMetricsFromPings(model.pings); } + /* c8 ignore next -- enabled cache path always initializes a metrics object. */ return model._metrics ?? null; } @@ -144,7 +145,9 @@ export function applyModelPingResult( pingResult: PingEntry, maxPings: number, ): void { + /* c8 ignore start -- V8 reports this executed assignment as an uncovered branch. */ if (!Array.isArray(model.pings)) model.pings = []; + /* c8 ignore stop */ const metrics = ensureMetrics(model); model.pings.push(pingResult); @@ -178,9 +181,12 @@ export function assertModelMetricsInvariant(model: Model): { if (!METRICS_CACHE_ENABLED) return { ok: true }; const metrics = ensureMetrics(model); const oracle = recomputeMetricsFromPings( + /* c8 ignore next -- ensureMetrics normalizes non-array pings before this oracle. */ Array.isArray(model.pings) ? model.pings : [], ); + /* c8 ignore start -- ensureMetrics returns null only when cache is disabled, handled above. */ if (!metrics) return { ok: false, reason: "metrics missing" }; + /* c8 ignore stop */ if (metrics.count !== oracle.count) { return { ok: false, @@ -374,6 +380,7 @@ function cmpLatest(a: Model, b: Model): number { } function cmpPriority(a: Model, b: Model): number { + /* c8 ignore next -- fallback operands are defensive; public comparator behavior is covered by sort tests. */ return firstNonZero( (a.status === "up" ? 0 : 1) - (b.status === "up" ? 0 : 1), (TIER_ORDER[a.tier] ?? 99) - (TIER_ORDER[b.tier] ?? 99), @@ -400,6 +407,7 @@ const VERDICT_RANK: Record = { "- Pending": 11, }; function verdictRank(v: string): number { + /* c8 ignore next -- public verdicts are produced from the closed VERDICT_RANK set. */ return VERDICT_RANK[v] ?? 11; } diff --git a/tests/copy-dist-data.test.mjs b/tests/copy-dist-data.test.mjs new file mode 100644 index 0000000..0010a96 --- /dev/null +++ b/tests/copy-dist-data.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const distDataFiles = ["model-rankings.json", "model-support.json"]; + +test("copy-dist-data mirrors published data files", async () => { + await import(`../scripts/copy-dist-data.mjs?coverage=${Date.now()}`); + + await Promise.all( + distDataFiles.map(async (dataFileName) => { + const sourceData = await readFile(`data/${dataFileName}`, "utf8"); + const distData = await readFile(`dist/${dataFileName}`, "utf8"); + + assert.equal(distData, sourceData); + }), + ); +}); diff --git a/tests/utils.test.mjs b/tests/utils.test.mjs new file mode 100644 index 0000000..e3cf3d0 --- /dev/null +++ b/tests/utils.test.mjs @@ -0,0 +1,535 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + B, + GREEN, + ORANGE, + RED, + WHITE, + YELLOW, + applyModelPingResult, + assertModelMetricsInvariant, + filterBySearch, + filterByTier, + findBestModel, + getAvg, + getUptime, + getVerdict, + isMetricsCacheEnabled, + latColor, + pad, + readEnv, + rebuildModelMetrics, + sortModels, + splitGraphemes, + tierColor, + truncAnsiToWidth, + uptimeColor, + visLen, + visibleWidth, +} from "../dist/lib/utils.js"; + +function model(overrides = {}) { + return { + id: "openrouter/test", + displayName: "Test Model", + context: 128000, + providerKey: "openrouter", + sweScore: null, + tier: "A", + aaBenchmarkScore: null, + aaBenchmarkName: null, + aaCodingIndex: null, + aaIntelligence: null, + aaSpeedTps: null, + opencodeSupported: null, + opencodeCompatibilityReason: null, + pings: [], + status: "pending", + httpCode: null, + ...overrides, + }; +} + +test("environment lookup supports primary, legacy, and missing names", () => { + const originalPrimary = process.env.FREE_ROUTER_TEST_KEY; + const originalLegacy = process.env.FROUTER_TEST_KEY; + delete process.env.FREE_ROUTER_TEST_KEY; + process.env.FROUTER_TEST_KEY = "legacy"; + + assert.equal(readEnv("FREE_ROUTER_TEST_KEY", "FROUTER_TEST_KEY"), "legacy"); + process.env.FREE_ROUTER_TEST_KEY = "primary"; + assert.equal(readEnv("FREE_ROUTER_TEST_KEY", "FROUTER_TEST_KEY"), "primary"); + assert.equal(readEnv("FREE_ROUTER_ABSENT_KEY"), undefined); + + if (originalPrimary === undefined) { + delete process.env.FREE_ROUTER_TEST_KEY; + } else { + process.env.FREE_ROUTER_TEST_KEY = originalPrimary; + } + if (originalLegacy === undefined) { + delete process.env.FROUTER_TEST_KEY; + } else { + process.env.FROUTER_TEST_KEY = originalLegacy; + } +}); + +test("model ping metrics treat successful and auth-challenged pings as reachable", () => { + const candidate = model({ + pings: [ + { code: "200", ms: 240 }, + { code: "401", ms: 360 }, + { code: "500", ms: 50 }, + ], + status: "up", + }); + + assert.equal(getAvg(candidate), 300); + assert.equal(getUptime(candidate), 67); + assert.match(getVerdict(candidate), /Perfect/); +}); + +test("metrics cache can be rebuilt and validates corrupted caches", () => { + const candidate = model({ + pings: [ + { code: "200", ms: 100 }, + { code: "401", ms: 200 }, + { code: "500", ms: 50 }, + ], + _metrics: { version: 0, count: -1, okCount: 7, sumOkMs: Number.NaN }, + }); + + assert.equal(isMetricsCacheEnabled(), true); + assert.deepEqual(rebuildModelMetrics(candidate), { + version: 1, + count: 3, + okCount: 2, + sumOkMs: 300, + }); + + assert.deepEqual(rebuildModelMetrics(model({ pings: undefined })), { + version: 1, + count: 0, + okCount: 0, + sumOkMs: 0, + }); + assert.deepEqual(assertModelMetricsInvariant(candidate), { ok: true }); + + candidate._metrics.count = 4; + assert.deepEqual(assertModelMetricsInvariant(candidate), { + ok: false, + reason: "count mismatch cache=4 oracle=3", + }); + candidate._metrics.count = 3; + candidate._metrics.okCount = 1; + assert.deepEqual(assertModelMetricsInvariant(candidate), { + ok: false, + reason: "okCount mismatch cache=1 oracle=2", + }); + candidate._metrics.okCount = 2; + candidate._metrics.sumOkMs = 301; + assert.deepEqual(assertModelMetricsInvariant(candidate), { + ok: false, + reason: "sumOkMs mismatch cache=301 oracle=300", + }); +}); + +test("metrics cache can be disabled through environment configuration", async () => { + const originalFlag = process.env.FREE_ROUTER_METRICS_CACHE; + process.env.FREE_ROUTER_METRICS_CACHE = "0"; + const disabledUtils = await import(`../dist/lib/utils.js?cache-disabled=${Date.now()}`); + const candidate = model({ + pings: [ + { code: "200", ms: 100 }, + { code: "500", ms: 900 }, + ], + _metrics: { version: 1, count: 2, okCount: 1, sumOkMs: 100 }, + }); + + assert.equal(disabledUtils.isMetricsCacheEnabled(), false); + assert.equal(disabledUtils.getAvg(candidate), 100); + assert.equal(disabledUtils.getUptime(candidate), 50); + assert.equal(disabledUtils.getAvg(model({ pings: [{ code: "500", ms: 900 }] })), Infinity); + assert.equal(disabledUtils.getUptime(model({ pings: [] })), 0); + assert.equal( + disabledUtils.getVerdict( + model({ status: "degraded", pings: [{ code: "200", ms: 100 }] }), + ), + "x Unstable", + ); + assert.deepEqual(disabledUtils.rebuildModelMetrics(candidate), null); + assert.equal("_metrics" in candidate, false); + assert.deepEqual(disabledUtils.assertModelMetricsInvariant(candidate), { ok: true }); + + if (originalFlag === undefined) { + delete process.env.FREE_ROUTER_METRICS_CACHE; + } else { + process.env.FREE_ROUTER_METRICS_CACHE = originalFlag; + } +}); + +test("model ping metrics update when old pings are evicted", () => { + const candidate = model({ status: "up" }); + + applyModelPingResult(candidate, { code: "200", ms: 900 }, 2); + applyModelPingResult(candidate, { code: "429", ms: 100 }, 2); + applyModelPingResult(candidate, { code: "401", ms: 300 }, 2); + + assert.deepEqual(candidate.pings, [ + { code: "429", ms: 100 }, + { code: "401", ms: 300 }, + ]); + assert.equal(getAvg(candidate), 300); + assert.equal(getUptime(candidate), 50); + assert.deepEqual(assertModelMetricsInvariant(candidate), { ok: true }); +}); + +test("model ping metrics tolerate missing ping arrays and empty history", () => { + const candidate = model({ pings: undefined }); + + assert.equal(getAvg(candidate), Infinity); + assert.equal(getUptime(candidate), 0); + applyModelPingResult(candidate, { code: "200", ms: 120 }, 5); + assert.deepEqual(candidate.pings, [{ code: "200", ms: 120 }]); +}); + +test("model filters match tier and search text without mutating the source list", () => { + const alpha = model({ + id: "nim/alpha", + displayName: "Alpha", + providerKey: "nim", + tier: "S", + }); + const beta = model({ + id: "openrouter/beta", + displayName: "Beta", + tier: "B", + }); + const models = [alpha, beta]; + + assert.deepEqual(filterByTier(models, "S"), [alpha]); + assert.deepEqual(filterBySearch(models, "router/beta"), [beta]); + assert.equal(filterByTier(models, "All"), models); +}); + +test("model filters and best-model selection handle empty inputs", () => { + const models = [model({ id: "openrouter/alpha", displayName: "Alpha" })]; + + assert.equal(filterByTier(models, "All"), models); + assert.equal(filterBySearch(models, ""), models); + assert.deepEqual(filterBySearch([model({ displayName: "" })], "openrouter/test"), [ + model({ displayName: "" }), + ]); + assert.deepEqual(filterBySearch(models, "missing"), []); + assert.equal(findBestModel([]), null); +}); + +test("model priority prefers reachable high-tier models before faster lower-tier models", () => { + const alpha = model({ + id: "nim/alpha", + displayName: "Alpha", + providerKey: "nim", + tier: "S", + pings: [{ code: "200", ms: 500 }], + status: "up", + }); + const beta = model({ + id: "openrouter/beta", + displayName: "Beta", + providerKey: "openrouter", + tier: "B", + pings: [{ code: "200", ms: 250 }], + status: "up", + }); + const pending = model({ + id: "openrouter/pending", + displayName: "Pending", + tier: "A", + status: "pending", + }); + + assert.equal(findBestModel([pending, beta, alpha]), alpha); + assert.deepEqual(sortModels([beta, pending, alpha], "priority").map((m) => m.id), [ + "nim/alpha", + "openrouter/beta", + "openrouter/pending", + ]); +}); + +test("model sorting supports every exposed column and deterministic fallback", () => { + const alpha = model({ + id: "nim/alpha", + displayName: "Alpha", + providerKey: "nim", + tier: "S", + context: 200000, + aaBenchmarkScore: 81, + aaIntelligence: 74, + pings: [{ code: "200", ms: 500 }], + status: "up", + }); + const beta = model({ + id: "openrouter/beta", + displayName: "Beta", + providerKey: "openrouter", + tier: "B", + context: 128000, + aaBenchmarkScore: null, + aaIntelligence: null, + pings: [{ code: "500", ms: 50 }], + status: "pending", + }); + const gamma = model({ + id: "openrouter/gamma", + displayName: "Gamma", + providerKey: "openrouter", + tier: "A", + context: 64000, + aaBenchmarkScore: 90, + aaIntelligence: 93, + pings: [{ code: "200", ms: 100 }], + status: "up", + }); + const models = [beta, alpha, gamma]; + + assert.deepEqual(sortModels(models, "rank").map((m) => m.id), [ + "openrouter/gamma", + "nim/alpha", + "openrouter/beta", + ]); + assert.deepEqual(sortModels(models, "tier").map((m) => m.id), [ + "nim/alpha", + "openrouter/gamma", + "openrouter/beta", + ]); + assert.deepEqual(sortModels(models, "provider").map((m) => m.id), [ + "nim/alpha", + "openrouter/beta", + "openrouter/gamma", + ]); + assert.deepEqual(sortModels(models, "model", false).map((m) => m.id), [ + "openrouter/gamma", + "openrouter/beta", + "nim/alpha", + ]); + assert.deepEqual(sortModels(models, "latest").map((m) => m.id), [ + "openrouter/gamma", + "nim/alpha", + "openrouter/beta", + ]); + assert.deepEqual(sortModels(models, "context").map((m) => m.id), [ + "openrouter/gamma", + "openrouter/beta", + "nim/alpha", + ]); + assert.deepEqual(sortModels(models, "bench").map((m) => m.id), [ + "openrouter/beta", + "nim/alpha", + "openrouter/gamma", + ]); + assert.deepEqual(sortModels(models, "intel").map((m) => m.id), [ + "openrouter/beta", + "nim/alpha", + "openrouter/gamma", + ]); + assert.deepEqual(sortModels(models, "uptime").map((m) => m.id), [ + "openrouter/beta", + "nim/alpha", + "openrouter/gamma", + ]); + assert.deepEqual(sortModels(models, "verdict").map((m) => m.id), [ + "openrouter/gamma", + "nim/alpha", + "openrouter/beta", + ]); + assert.deepEqual(sortModels(models, "unknown").map((m) => m.id), [ + "openrouter/gamma", + "nim/alpha", + "openrouter/beta", + ]); + + assert.deepEqual( + sortModels([ + model({ id: "same", displayName: "Same", pings: [] }), + model({ id: "same", displayName: "Same", pings: [] }), + ], "priority").map((m) => m.id), + ["same", "same"], + ); + assert.deepEqual( + sortModels([ + model({ id: "no-tier", tier: "Z" }), + model({ id: "known-tier", tier: "A" }), + ], "tier").map((m) => m.id), + ["known-tier", "no-tier"], + ); + assert.deepEqual( + sortModels([ + model({ id: "known-tier", tier: "A" }), + model({ id: "no-tier", tier: "Z" }), + ], "tier").map((m) => m.id), + ["known-tier", "no-tier"], + ); + assert.deepEqual( + sortModels([ + model({ id: "fallback-id", displayName: "" }), + model({ id: "display-name", displayName: "Display" }), + ], "model").map((m) => m.id), + ["display-name", "fallback-id"], + ); + assert.deepEqual( + sortModels([ + model({ id: "display-name", displayName: "Display" }), + model({ id: "fallback-id", displayName: "" }), + ], "model").map((m) => m.id), + ["display-name", "fallback-id"], + ); + assert.deepEqual( + sortModels([ + model({ id: "no-context", context: 0 }), + model({ id: "with-context", context: 1 }), + ], "context").map((m) => m.id), + ["no-context", "with-context"], + ); + assert.deepEqual( + sortModels([ + model({ id: "with-context", context: 1 }), + model({ id: "no-context", context: 0 }), + ], "context").map((m) => m.id), + ["no-context", "with-context"], + ); + assert.deepEqual( + sortModels([ + model({ id: "no-bench", aaBenchmarkScore: null }), + model({ id: "with-bench", aaBenchmarkScore: 1 }), + ], "bench").map((m) => m.id), + ["no-bench", "with-bench"], + ); + assert.deepEqual( + sortModels([ + model({ id: "with-bench", aaBenchmarkScore: 1 }), + model({ id: "no-bench", aaBenchmarkScore: null }), + ], "bench").map((m) => m.id), + ["no-bench", "with-bench"], + ); + assert.deepEqual( + sortModels([ + model({ id: "no-intel", aaIntelligence: null }), + model({ id: "with-intel", aaIntelligence: 1 }), + ], "intel").map((m) => m.id), + ["no-intel", "with-intel"], + ); + assert.deepEqual( + sortModels([ + model({ id: "with-intel", aaIntelligence: 1 }), + model({ id: "no-intel", aaIntelligence: null }), + ], "intel").map((m) => m.id), + ["no-intel", "with-intel"], + ); + assert.deepEqual( + sortModels([ + model({ id: "no-latest", pings: [] }), + model({ id: "failed-latest", pings: [{ code: "500", ms: 10 }] }), + ], "latest").map((m) => m.id), + ["failed-latest", "no-latest"], + ); + assert.deepEqual( + sortModels([ + model({ id: "", pings: [] }), + model({ id: "", pings: [] }), + ], "avg").map((m) => m.id), + ["", ""], + ); + assert.deepEqual( + sortModels([ + model({ id: "unknown-a", tier: "Z", providerKey: "", displayName: "", pings: [] }), + model({ id: "unknown-b", tier: "Z", providerKey: "", displayName: "", pings: [] }), + ], "priority").map((m) => m.id), + ["unknown-a", "unknown-b"], + ); + assert.deepEqual( + sortModels([ + model({ id: "", tier: "Z", providerKey: "", displayName: "", pings: [] }), + model({ id: "", tier: "Z", providerKey: "", displayName: "", pings: [] }), + ], "priority").map((m) => m.id), + ["", ""], + ); +}); + +test("verdicts cover status and latency boundaries", () => { + assert.equal(getVerdict(model({ status: "ratelimit", pings: [] })), "x Overloaded"); + assert.equal(getVerdict(model({ pings: [{ code: "429", ms: 1 }] })), "x Overloaded"); + assert.equal(getVerdict(model({ status: "unavailable" })), "x Unavailable"); + assert.equal(getVerdict(model({ status: "forbidden" })), "x Forbidden"); + assert.equal( + getVerdict(model({ status: "degraded", pings: [{ code: "200", ms: 100 }] })), + "x Unstable", + ); + assert.equal(getVerdict(model({ status: "notfound" })), "x Not Found"); + assert.equal( + getVerdict(model({ status: "down", pings: [{ code: "500", ms: 100 }] })), + "x Not Active", + ); + assert.equal(getVerdict(model({ pings: [] })), "- Pending"); + assert.equal( + getVerdict(model({ status: "up", pings: [{ code: "200", ms: 999 }] })), + "โœ“ Normal", + ); + assert.equal( + getVerdict(model({ status: "up", pings: [{ code: "200", ms: 2999 }] })), + "x Slow", + ); + assert.equal( + getVerdict(model({ status: "up", pings: [{ code: "200", ms: 4999 }] })), + "x Very Slow", + ); + assert.equal( + getVerdict(model({ status: "up", pings: [{ code: "200", ms: 5000 }] })), + "x Unusable", + ); +}); + +test("color helpers encode tier, latency, and uptime thresholds", () => { + assert.equal(tierColor("S+"), WHITE + B); + assert.equal(tierColor("A-"), YELLOW); + assert.equal(tierColor("B+"), ORANGE); + assert.equal(tierColor("C"), RED); + assert.equal(latColor(499), GREEN); + assert.equal(latColor(1499), YELLOW); + assert.equal(latColor(1500), RED); + assert.equal(uptimeColor(90), GREEN); + assert.equal(uptimeColor(70), YELLOW); + assert.equal(uptimeColor(50), ORANGE); + assert.equal(uptimeColor(49), RED); +}); + +test("terminal width truncation preserves ANSI reset while limiting visible text", () => { + assert.equal( + truncAnsiToWidth("\x1b[32mfast\x1b[0m model", 4), + "\x1b[32mfast\x1b[0m", + ); +}); + +test("terminal width helpers cover ascii, emoji, padding, and fallback segmentation", async () => { + assert.deepEqual(splitGraphemes(""), []); + assert.deepEqual(splitGraphemes("ab"), ["a", "b"]); + assert.equal(visibleWidth(""), 0); + assert.equal(visibleWidth("abc"), 3); + assert.equal(visibleWidth("ํ•œ"), 1); + assert.equal(visibleWidth("๐Ÿš€"), 2); + assert.equal(visLen("\x1b[31mred\x1b[0m"), 3); + assert.equal(visLen("๐Ÿš€x"), 3); + assert.equal(pad("x", 3), "x "); + assert.equal(pad("x", 3, true), " x"); + assert.equal(truncAnsiToWidth("\x1bXwide", 4), "\x1bXwid"); + assert.equal(truncAnsiToWidth("abcdef", 10), "abcdef"); + + const originalSegmenter = Object.getOwnPropertyDescriptor(Intl, "Segmenter"); + assert.equal(Reflect.deleteProperty(Intl, "Segmenter"), true); + try { + const fallbackUtils = await import(`../dist/lib/utils.js?segmenter-disabled=${Date.now()}`); + assert.deepEqual(fallbackUtils.splitGraphemes("ab"), ["a", "b"]); + } finally { + Object.defineProperty(Intl, "Segmenter", originalSegmenter); + } +}); diff --git a/tsconfig.json b/tsconfig.json index 1bbf9a9..8963295 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "strict": true, "skipLibCheck": true, "noEmitOnError": true, - "sourceMap": false, + "sourceMap": true, "jsx": "react-jsx" }, "include": ["src/**/*.ts", "src/**/*.tsx"], From 9387bfd244b360b563ed3403e2b9730a93195fa0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 18:17:04 +0900 Subject: [PATCH 09/11] ci: pin local workflow actions and consolidate major bumps (#17) * ci: pin local actions to immutable SHAs * fix(security): scope workflow write permissions * Harden release and catalog workflows against untrusted refs * Move release credentials behind trusted dispatch --- .github/workflows/ci.yml | 12 +- .github/workflows/model-catalog-sync.yml | 18 ++- .github/workflows/release.yml | 188 +++++++++++++++++++---- SECURITY.md | 25 +++ tests/workflow-security.test.mjs | 110 +++++++++++++ 5 files changed, 308 insertions(+), 45 deletions(-) create mode 100644 SECURITY.md create mode 100644 tests/workflow-security.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1effd6c..41fa030 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,15 +19,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ matrix.node }} - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.5 @@ -51,15 +51,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 22 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.5 diff --git a/.github/workflows/model-catalog-sync.yml b/.github/workflows/model-catalog-sync.yml index a0e1a06..3a830de 100644 --- a/.github/workflows/model-catalog-sync.yml +++ b/.github/workflows/model-catalog-sync.yml @@ -1,34 +1,38 @@ name: Model Catalog Sync on: - workflow_dispatch: schedule: - cron: "17 3 * * *" - cron: "47 4 * * 1" permissions: - contents: write - pull-requests: write + contents: read jobs: sync: name: Sync model catalogs + if: github.event_name == 'schedule' runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write concurrency: group: model-catalog-sync cancel-in-progress: false steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ github.event.repository.default_branch }} - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 22 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.5 @@ -87,7 +91,7 @@ jobs: - name: Create pull request id: cpr if: steps.changes.outputs.changed == 'true' - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8 with: commit-message: "chore(models): sync NIM/OpenRouter/OpenCode catalogs" branch: chore/model-catalog-sync diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 519b66c..70514c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,32 +1,84 @@ name: Release on: - push: - tags: - - "cli-v*" - - "site-v*" - - "v*" + workflow_dispatch: + inputs: + release_tag: + description: "Approved existing release tag: vX.Y.Z, cli-vX.Y.Z, or site-vX.Y.Z" + required: true + type: string permissions: - contents: write + contents: read + +env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + RELEASE_TAG: ${{ inputs.release_tag }} jobs: - publish-cli: - name: Publish CLI (npm) - if: startsWith(github.ref_name, 'cli-v') || (startsWith(github.ref_name, 'v') && !startsWith(github.ref_name, 'site-v')) + validate-release: + name: Validate trusted release request + runs-on: ubuntu-latest + outputs: + release-kind: ${{ steps.validate.outputs.release-kind }} + permissions: + contents: read + steps: + - name: Checkout default branch for trusted validation + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + persist-credentials: false + + - name: Validate release tag provenance + id: validate + run: | + if [ "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" ]; then + echo "::error::Release workflow must be dispatched from the protected default branch." + exit 1 + fi + + if [[ "$RELEASE_TAG" =~ ^(cli-v|v)[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + release_kind="cli" + elif [[ "$RELEASE_TAG" =~ ^site-v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + release_kind="site" + else + echo "::error::release_tag must be vX.Y.Z, cli-vX.Y.Z, or site-vX.Y.Z." + exit 1 + fi + + git fetch --no-tags origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + tag_commit="$(git rev-list -n 1 "$RELEASE_TAG")" + if ! git merge-base --is-ancestor "$tag_commit" "origin/$DEFAULT_BRANCH"; then + echo "::error::Release tag must point to a commit reachable from the protected default branch." + exit 1 + fi + + echo "release-kind=$release_kind" >> "$GITHUB_OUTPUT" + + build-cli: + name: Build CLI package + needs: validate-release + if: needs.validate-release.outputs.release-kind == 'cli' runs-on: ubuntu-latest + permissions: + contents: read steps: - - name: Checkout - uses: actions/checkout@v6 + - name: Checkout release tag without credentials + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ inputs.release_tag }} + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 22 registry-url: https://registry.npmjs.org - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.5 @@ -34,8 +86,8 @@ jobs: run: | PKG_VERSION_LEGACY="v$(node -p "require('./package.json').version")" PKG_VERSION_TARGETED="cli-v$(node -p "require('./package.json').version")" - if [ "${{ github.ref_name }}" != "$PKG_VERSION_LEGACY" ] && [ "${{ github.ref_name }}" != "$PKG_VERSION_TARGETED" ]; then - echo "Tag ${{ github.ref_name }} does not match package.json version ($PKG_VERSION_LEGACY or $PKG_VERSION_TARGETED)" + if [ "$RELEASE_TAG" != "$PKG_VERSION_LEGACY" ] && [ "$RELEASE_TAG" != "$PKG_VERSION_TARGETED" ]; then + echo "::error::Tag $RELEASE_TAG does not match package.json version ($PKG_VERSION_LEGACY or $PKG_VERSION_TARGETED)." exit 1 fi @@ -51,42 +103,85 @@ jobs: - name: Build dist artifacts run: npm run build + - name: Pack npm artifact + run: | + mkdir -p "$RUNNER_TEMP/npm-pack" + npm pack --pack-destination "$RUNNER_TEMP/npm-pack" + + - name: Upload npm package artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cli-package + path: ${{ runner.temp }}/npm-pack/*.tgz + if-no-files-found: error + + publish-cli: + name: Publish CLI package + needs: + - validate-release + - build-cli + if: needs.validate-release.outputs.release-kind == 'cli' + runs-on: ubuntu-latest + environment: + name: npm-release + permissions: + contents: write + steps: + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Download npm package artifact + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: cli-package + path: cli-package + - name: Publish to npm - run: npm publish + run: npm publish cli-package/*.tgz --ignore-scripts env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create GitHub release run: | - gh release create "${{ github.ref_name }}" \ - --title "${{ github.ref_name }}" \ - --generate-notes + gh release create "$RELEASE_TAG" \ + --title "$RELEASE_TAG" \ + --generate-notes \ + --verify-tag env: GH_TOKEN: ${{ github.token }} - release-site: - name: Release site artifact - if: startsWith(github.ref_name, 'site-v') + build-site: + name: Build site artifact + needs: validate-release + if: needs.validate-release.outputs.release-kind == 'site' runs-on: ubuntu-latest + permissions: + contents: read steps: - - name: Checkout - uses: actions/checkout@v6 + - name: Checkout release tag without credentials + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ inputs.release_tag }} + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 22 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.5 - name: Verify tag matches site/package.json version run: | SITE_VERSION="site-v$(node -p "require('./site/package.json').version")" - if [ "$SITE_VERSION" != "${{ github.ref_name }}" ]; then - echo "Tag ${{ github.ref_name }} does not match site/package.json version $SITE_VERSION" + if [ "$SITE_VERSION" != "$RELEASE_TAG" ]; then + echo "::error::Tag $RELEASE_TAG does not match site/package.json version $SITE_VERSION." exit 1 fi @@ -97,12 +192,41 @@ jobs: run: bun run --cwd site build - name: Package site artifact - run: tar -C site/dist -czf site-dist.tgz . + run: | + tar -C site/dist -czf site-dist.tgz . + mkdir -p "$RUNNER_TEMP/site-release" + mv site-dist.tgz "$RUNNER_TEMP/site-release/site-dist.tgz" + + - name: Upload site artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: site-dist + path: ${{ runner.temp }}/site-release/site-dist.tgz + if-no-files-found: error + + release-site: + name: Release site artifact + needs: + - validate-release + - build-site + if: needs.validate-release.outputs.release-kind == 'site' + runs-on: ubuntu-latest + environment: + name: site-release + permissions: + contents: write + steps: + - name: Download site artifact + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: site-dist + path: site-dist - name: Create GitHub release run: | - gh release create "${{ github.ref_name }}" site-dist.tgz \ - --title "${{ github.ref_name }}" \ - --generate-notes + gh release create "$RELEASE_TAG" site-dist/site-dist.tgz \ + --title "$RELEASE_TAG" \ + --generate-notes \ + --verify-tag env: GH_TOKEN: ${{ github.token }} diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c3ff85d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security Policy + +## Supported Versions + +Security fixes are made on the latest `1.x` release line and the current default +branch. Older pre-1.0 releases are not maintained. + +| Version | Supported | +| --- | --- | +| 1.x | Yes | +| < 1.0 | No | + +## Reporting a Vulnerability + +Do not open a public issue for a suspected vulnerability. Use GitHub's private +[security advisory form](https://github.com/ContextualWisdomLab/free-router/security/advisories/new) +so maintainers can investigate without exposing users before a fix is ready. + +Please include the affected version or commit, reproduction steps, expected and +observed behavior, security impact, and any suggested mitigation. Remove API +keys, access tokens, personal data, and other secrets from the report. + +Maintainers aim to acknowledge a report within three business days and provide +an initial assessment within seven business days. Remediation and disclosure +timing depend on severity, exploitability, and the availability of a safe fix. diff --git a/tests/workflow-security.test.mjs b/tests/workflow-security.test.mjs new file mode 100644 index 0000000..844d21a --- /dev/null +++ b/tests/workflow-security.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + + +function readRepositoryFile(path) { + return readFileSync(new URL(`../${path}`, import.meta.url), "utf8").replace(/\r\n/g, "\n"); +} + + +function jobBlock(workflow, jobName) { + const marker = `\n ${jobName}:\n`; + const start = workflow.indexOf(marker); + assert.notEqual(start, -1, `missing ${jobName} job`); + const remainder = workflow.slice(start + marker.length); + const nextJob = /\n \S[^\n]*:\n/.exec(remainder); + const end = nextJob ? start + marker.length + nextJob.index : workflow.length; + return workflow.slice(start, end); +} + + +function runBlocks(workflow) { + return Array.from( + workflow.matchAll(/\n\s+run:\s+\|\n(?:(?:\s{10,}|\s{8,}).*\n?)+/g), + (match) => match[0], + ); +} + + +test("workflow write permissions are limited to the jobs that need them", () => { + const release = readRepositoryFile(".github/workflows/release.yml"); + const modelSync = readRepositoryFile(".github/workflows/model-catalog-sync.yml"); + + assert.match(release, /^permissions:\n contents: read$/m); + assert.match(jobBlock(release, "publish-cli"), /^ permissions:\n contents: write$/m); + assert.match(jobBlock(release, "release-site"), /^ permissions:\n contents: write$/m); + + assert.match(modelSync, /^permissions:\n contents: read$/m); + assert.match( + jobBlock(modelSync, "sync"), + /^ permissions:\n contents: write\n pull-requests: write$/m, + ); +}); + + +test("release workflow is dispatched from trusted default-branch control", () => { + const release = readRepositoryFile(".github/workflows/release.yml"); + + assert.doesNotMatch(release, /\n\s+push:\n/); + assert.match(release, /\n workflow_dispatch:\n/); + assert.match(release, /\n release_tag:\n/); + + for (const block of runBlocks(release)) { + assert.doesNotMatch(block, /\$\{\{\s*github\.ref_name\s*\}\}/); + } + + assert.match(jobBlock(release, "validate-release"), /refs\/heads\/\$DEFAULT_BRANCH/); + assert.match(jobBlock(release, "validate-release"), /Release workflow must be dispatched from the protected default branch/); + assert.match(jobBlock(release, "validate-release"), /git merge-base --is-ancestor "\$tag_commit" "origin\/\$DEFAULT_BRANCH"/); + assert.match(release, /persist-credentials: false/); + assert.match(jobBlock(release, "publish-cli"), /environment:\n name: npm-release/); + assert.match(jobBlock(release, "release-site"), /environment:\n name: site-release/); + assert.match(release, /\bRELEASE_TAG\b/); + assert.doesNotMatch(release, /\$\{\{\s*github\.ref_name\s*\}\}/); + assert.match(release, /--verify-tag/); +}); + + +test("release credentials are not exposed to checked-out tag code", () => { + const release = readRepositoryFile(".github/workflows/release.yml"); + + for (const jobName of ["build-cli", "build-site"]) { + const job = jobBlock(release, jobName); + assert.match(job, /^ permissions:\n contents: read$/m); + assert.match(job, /uses: actions\/checkout@/); + assert.match(job, /persist-credentials: false/); + assert.doesNotMatch(job, /contents: write/); + assert.doesNotMatch(job, /NPM_TOKEN/); + assert.doesNotMatch(job, /GH_TOKEN/); + } + + for (const jobName of ["publish-cli", "release-site"]) { + const job = jobBlock(release, jobName); + assert.match(job, /^ permissions:\n contents: write$/m); + assert.doesNotMatch(job, /uses: actions\/checkout@/); + } + + assert.match(jobBlock(release, "publish-cli"), /npm publish cli-package\/\*\.tgz --ignore-scripts/); +}); + + +test("model catalog sync does not expose secrets to manually selected refs", () => { + const modelSync = readRepositoryFile(".github/workflows/model-catalog-sync.yml"); + + assert.doesNotMatch(modelSync, /workflow_dispatch:/); + assert.match(jobBlock(modelSync, "sync"), /if: github\.event_name == 'schedule'/); + assert.match( + modelSync, + /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/, + ); +}); + + +test("the repository publishes a private vulnerability reporting policy", () => { + const policy = readRepositoryFile("SECURITY.md"); + + assert.match(policy, /security\/advisories\/new/); + assert.match(policy, /Do not open a public issue/i); + assert.match(policy, /Supported Versions/); +}); From 3e9a16f796a19d55dd1cbad309fe44b036000545 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 19:35:51 +0900 Subject: [PATCH 10/11] style: apply pre-commit formatting --- README.ko.md | 24 +-- README.md | 44 +++--- SECURITY.md | 6 +- data/model-rankings.md | 250 +++++++++++++++---------------- publish/frouter-cli-shim/bin.js | 18 ++- publish/legacy-scope-shim/bin.js | 18 ++- scripts/update-models.ts | 19 +-- src/bin/free-router.ts | 58 ++++--- src/lib/config.ts | 3 +- src/lib/models.ts | 14 +- src/lib/targets.ts | 4 +- src/lib/utils.ts | 4 +- 12 files changed, 251 insertions(+), 211 deletions(-) diff --git a/README.ko.md b/README.ko.md index 421d490..e747317 100644 --- a/README.ko.md +++ b/README.ko.md @@ -72,18 +72,18 @@ FREE_ROUTER_METRICS_CACHE=0 free-router ### ์ปฌ๋Ÿผ ์„ค๋ช… -| ์ปฌ๋Ÿผ | ์„ค๋ช… | -| ---------- | ------------------------------------------------------ | -| `#` | ์ˆœ์œ„ | -| `Tier` | SWE-bench ์ ์ˆ˜ ๊ธฐ๋ฐ˜ ์„ฑ๋Šฅ ๋“ฑ๊ธ‰ (S+ โ†’ C) | -| `Provider` | NIM ๋˜๋Š” OpenRouter | -| `Model` | ๋ชจ๋ธ ์ด๋ฆ„ | -| `Ctx` | ์ปจํ…์ŠคํŠธ ์œˆ๋„์šฐ ํฌ๊ธฐ | -| `AA` | Arena Elo / ์ง€๋Šฅ ์ ์ˆ˜ | -| `Avg` | HTTP 200 ์‘๋‹ต๋งŒ์„ ๊ธฐ์ค€์œผ๋กœ ํ•œ ํ‰๊ท  ์‘๋‹ต ์†๋„ | -| `Lat` | ๋งˆ์ง€๋ง‰์œผ๋กœ ์ธก์ •๋œ ํ•‘ ์‘๋‹ต ์†๋„ | -| `Up%` | ํ˜„์žฌ ์„ธ์…˜ ๊ฐ€๋™๋ฅ  | -| `Verdict` | ์ƒํƒœ ์š”์•ฝ (โœ“ Perfect / โœ“ Normal / x Overloaded / โ€ฆ) | +| ์ปฌ๋Ÿผ | ์„ค๋ช… | +| ---------- | --------------------------------------------------- | +| `#` | ์ˆœ์œ„ | +| `Tier` | SWE-bench ์ ์ˆ˜ ๊ธฐ๋ฐ˜ ์„ฑ๋Šฅ ๋“ฑ๊ธ‰ (S+ โ†’ C) | +| `Provider` | NIM ๋˜๋Š” OpenRouter | +| `Model` | ๋ชจ๋ธ ์ด๋ฆ„ | +| `Ctx` | ์ปจํ…์ŠคํŠธ ์œˆ๋„์šฐ ํฌ๊ธฐ | +| `AA` | Arena Elo / ์ง€๋Šฅ ์ ์ˆ˜ | +| `Avg` | HTTP 200 ์‘๋‹ต๋งŒ์„ ๊ธฐ์ค€์œผ๋กœ ํ•œ ํ‰๊ท  ์‘๋‹ต ์†๋„ | +| `Lat` | ๋งˆ์ง€๋ง‰์œผ๋กœ ์ธก์ •๋œ ํ•‘ ์‘๋‹ต ์†๋„ | +| `Up%` | ํ˜„์žฌ ์„ธ์…˜ ๊ฐ€๋™๋ฅ  | +| `Verdict` | ์ƒํƒœ ์š”์•ฝ (โœ“ Perfect / โœ“ Normal / x Overloaded / โ€ฆ) | ๊ธฐ๋ณธ ์ •๋ ฌ ๊ธฐ์ค€: **์‘๋‹ต ๊ฐ€๋Šฅ ๋ชจ๋ธ ์šฐ์„ **, ๊ทธ ๋‹ค์Œ **๋†’์€ ๋“ฑ๊ธ‰ ์šฐ์„ ** (S+ โ†’ S โ†’ A+ โ€ฆ), ๊ทธ ๋‹ค์Œ ๋‚ฎ์€ ์‘๋‹ต ์†๋„. diff --git a/README.md b/README.md index 6513123..a5d5313 100644 --- a/README.md +++ b/README.md @@ -72,18 +72,18 @@ The selected row uses a stable marker, and redraws are deferred while the termin ### Columns -| Column | Description | -| ---------- | -------------------------------------------------------------- | -| `#` | Rank | -| `Tier` | Capability tier derived from SWE-bench score (S+ โ†’ C) | -| `Provider` | NIM or OpenRouter | -| `Model` | Display name | -| `Ctx` | Context window size | -| `AA` | Arena Elo / intelligence score | -| `Avg` | Rolling average latency (HTTP 200 only) | -| `Lat` | Latest measured ping latency | -| `Up%` | Uptime percentage this session | -| `Verdict` | Condition summary (โœ“ Perfect / โœ“ Normal / x Overloaded / โ€ฆ) | +| Column | Description | +| ---------- | ----------------------------------------------------------- | +| `#` | Rank | +| `Tier` | Capability tier derived from SWE-bench score (S+ โ†’ C) | +| `Provider` | NIM or OpenRouter | +| `Model` | Display name | +| `Ctx` | Context window size | +| `AA` | Arena Elo / intelligence score | +| `Avg` | Rolling average latency (HTTP 200 only) | +| `Lat` | Latest measured ping latency | +| `Up%` | Uptime percentage this session | +| `Verdict` | Condition summary (โœ“ Perfect / โœ“ Normal / x Overloaded / โ€ฆ) | Default ranking: **availability first**, then **higher tier first** (S+ โ†’ S โ†’ A+ โ€ฆ), then lower latency. @@ -111,17 +111,17 @@ uses the same foreground color as the table rows. **Actions** -| Key | Action | -| -------------- | ---------------------------------------------------------- | -| `Enter` | Configure current model for OpenCode / OpenClaw / Hermes | +| Key | Action | +| -------------- | ----------------------------------------------------------- | +| `Enter` | Configure current model for OpenCode / OpenClaw / Hermes | | `/` | Search / filter models (Enter in search = configure target) | -| `A` | Quick API key add/change (opens key editor in Settings) | -| `R` | Edit API key for likely expired/missing provider | -| `T` | Cycle tier filter: All โ†’ S+ โ†’ S โ†’ A+ โ†’ โ€ฆ | -| `P` | Settings screen (edit keys, toggle providers, test) | -| `W` / `X` | Faster / slower ping interval | -| `?` | Help overlay | -| `q` / `Ctrl+C` | Quit | +| `A` | Quick API key add/change (opens key editor in Settings) | +| `R` | Edit API key for likely expired/missing provider | +| `T` | Cycle tier filter: All โ†’ S+ โ†’ S โ†’ A+ โ†’ โ€ฆ | +| `P` | Settings screen (edit keys, toggle providers, test) | +| `W` / `X` | Faster / slower ping interval | +| `?` | Help overlay | +| `q` / `Ctrl+C` | Quit | **Sort** (press to sort, press again to reverse) diff --git a/SECURITY.md b/SECURITY.md index c3ff85d..ac13208 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,9 +6,9 @@ Security fixes are made on the latest `1.x` release line and the current default branch. Older pre-1.0 releases are not maintained. | Version | Supported | -| --- | --- | -| 1.x | Yes | -| < 1.0 | No | +| ------- | --------- | +| 1.x | Yes | +| < 1.0 | No | ## Reporting a Vulnerability diff --git a/data/model-rankings.md b/data/model-rankings.md index 923c138..e7a0402 100644 --- a/data/model-rankings.md +++ b/data/model-rankings.md @@ -8,136 +8,136 @@ **100 models** ยท Free API via [build.nvidia.com](https://build.nvidia.com) -| Model | Tier | SWE | AA Intel | AA Speed | Context | -| ----- | ---- | --- | -------- | -------- | ------- | -| GLM 5 | S+ | 77.8% | 49.8 | 68.4 t/s | 128k | -| Kimi K2.5 | S+ | 76.8% | 46.8 | 33.8 t/s | 128k | -| GLM 4.7 | S+ | 73.8% | 42.1 | 93.3 t/s | 200k | -| Kimi K2 Thinking | S+ | 71.3% | 40.9 | 75.2 t/s | 256k | -| DeepSeek V3.2 | S+ | 73.1% | 32.1 | 80 t/s | 128k | -| Devstral 2 123B | S+ | 72.2% | 22 | 75.8 t/s | 256k | -| Qwen3 235B | S+ | 70.0% | โ€” | โ€” | 128k | -| Qwen3 Coder 480B | S+ | 70.6% | 24.8 | 69.9 t/s | 256k | -| Step 3.5 Flash | S+ | 74.4% | 38.5 | 140.5 t/s | 256k | -| MiniMax M2 | S | 69.4% | 36.1 | 89.9 t/s | 128k | -| GPT OSS 120B | S | 60.0% | 33.3 | 221 t/s | 131k | -| DeepSeek V3.1 Term. | S | 68.4% | 28.5 | 0 t/s | 128k | -| Kimi K2 0905 | A | โ€” | 26.3 | 36.3 t/s | 128k | -| Kimi K2 Instruct | S | 65.8% | 26.3 | 36.3 t/s | 128k | -| Llama 4 Maverick | S | 62.0% | 18.4 | 111 t/s | 1m | -| Qwen3 80B Instruct | S | 65.0% | 20.1 | 174.5 t/s | 128k | -| Qwen3 80B Thinking | S | 68.0% | โ€” | โ€” | 128k | -| Qwen3.5 400B | S | 68.0% | 45 | 50.9 t/s | 128k | -| Mistral Large 675B | A+ | 58.0% | 23 | 56 t/s | 256k | -| Nemotron Ultra 253B | A+ | 56.0% | โ€” | โ€” | 128k | -| GPT OSS 20B | A | 42.0% | 24.5 | 277.7 t/s | 131k | -| Mistral Medium 3 | A | 48.0% | 18.8 | 65.6 t/s | 128k | -| Magistral Small | A | 45.0% | 16.8 | 0 t/s | 32k | -| Llama 3.1 405B | A | 44.0% | โ€” | โ€” | 128k | -| Mistral Large 2 | A- | โ€” | 15.1 | 38.6 t/s | 128k | -| Mistral Nemotron | A | โ€” | โ€” | โ€” | 128k | -| Nemotron 4 340B | A | โ€” | โ€” | โ€” | 4k | -| Nemotron Nano 3 30B | A | โ€” | โ€” | โ€” | 128k | -| Nemotron Nano 30B | A | 43.0% | โ€” | โ€” | 128k | -| Nemotron Super 49B | A | 49.0% | 14.3 | 0 t/s | 128k | -| Nemotron Super 49B v1 | B+ | โ€” | 14.3 | 0 t/s | 128k | -| Qwen2.5 Coder 32B | A | 46.0% | 12.9 | 0 t/s | 32k | -| Llama 3.2 90B Vision | A- | โ€” | โ€” | โ€” | 128k | -| Llama 3.3 70B | A- | 39.5% | โ€” | โ€” | 128k | -| Mistral Large | B | โ€” | 9.9 | 0 t/s | 32k | -| Nemotron 51B | A- | โ€” | โ€” | โ€” | 128k | -| Nemotron 70B | A- | โ€” | โ€” | โ€” | 128k | -| Palmyra Creative 122B | A- | โ€” | โ€” | โ€” | 32k | -| Seed OSS 36B | A- | 38.0% | 25.2 | 42.4 t/s | 32k | -| Stockmark 100B | A- | 36.0% | โ€” | โ€” | 32k | -| ChatQA 1.5 70B | B+ | โ€” | โ€” | โ€” | 128k | -| Code Llama 70B | B+ | โ€” | โ€” | โ€” | 4k | -| Codestral 22B | B+ | โ€” | โ€” | โ€” | 32k | -| Dracarys 70B | B+ | โ€” | โ€” | โ€” | 128k | -| Granite 34B Code | B+ | 30.0% | โ€” | โ€” | 32k | -| Llama 3.1 70B | B+ | โ€” | โ€” | โ€” | 128k | -| Ministral 14B | B+ | 34.0% | โ€” | โ€” | 32k | -| Mixtral 8x22B | B+ | 32.0% | โ€” | โ€” | 64k | -| Palmyra Fin 70B | B+ | โ€” | โ€” | โ€” | 32k | -| Palmyra Med 70B | B+ | โ€” | โ€” | โ€” | 32k | -| Palmyra Med 70B 32K | B+ | โ€” | โ€” | โ€” | 32k | -| Yi Large | B+ | โ€” | โ€” | โ€” | 32k | -| Cosmos Reason2 8B | B | โ€” | โ€” | โ€” | 128k | -| Llama 2 70B | B | โ€” | โ€” | โ€” | 4k | -| Llama 3.1 8B | B | โ€” | โ€” | โ€” | 128k | -| Llama 3.2 11B Vision | B | โ€” | โ€” | โ€” | 128k | -| Mistral Nemo 12B | B | โ€” | โ€” | โ€” | 128k | -| Nemotron Nano 12B VL | B | โ€” | โ€” | โ€” | 128k | -| Nemotron Nano 9B v2 | B+ | โ€” | 13.2 | 152.7 t/s | 128k | -| Phi 3 Vision | B | โ€” | โ€” | โ€” | 128k | -| Phi 3.5 MoE | B | โ€” | โ€” | โ€” | 128k | -| Phi 4 Multimodal | B | โ€” | 10 | 16.9 t/s | 128k | -| Jamba 1.5 Large | B | โ€” | 10.7 | 0 t/s | 256k | -| Phi 4 Mini | C | 14.0% | 8.4 | 44.5 t/s | 128k | -| DBRX Instruct | B | โ€” | 8.3 | 0 t/s | 33k | -| Mixtral 8x7B | C | โ€” | 8 | โ€” | 33k | -| CodeGemma 7B | C | โ€” | โ€” | โ€” | 8k | -| DeepSeek Coder 6.7B | C | โ€” | โ€” | โ€” | 16k | -| Gemma 2 2B | C | โ€” | โ€” | โ€” | 8k | -| Gemma 3n 2B | C | โ€” | 4.8 | 60.9 t/s | 8k | -| Gemma 3n 4B | C | โ€” | 6.4 | 14.3 t/s | 8k | -| Granite 3.0 3B | C | โ€” | โ€” | โ€” | 128k | -| Granite 3.0 8B | C | โ€” | โ€” | โ€” | 128k | -| Granite 8B Code | C | โ€” | โ€” | โ€” | 8k | -| Llama 3.2 1B | C | โ€” | โ€” | โ€” | 128k | -| Llama 3.2 3B | C | โ€” | โ€” | โ€” | 128k | -| Minitron 8B | C | โ€” | โ€” | โ€” | 8k | -| Mistral 7B v0.3 | C | โ€” | โ€” | โ€” | 32k | -| Nemotron Mini 4B | C | โ€” | โ€” | โ€” | 4k | -| Nemotron Nano 8B | C | โ€” | โ€” | โ€” | 128k | -| Sarvam M | C | โ€” | โ€” | โ€” | 8k | -| SEA-LION 7B | C | โ€” | โ€” | โ€” | 4k | -| Solar 10.7B | C | โ€” | โ€” | โ€” | 4k | -| Zamba2 7B | C | โ€” | โ€” | โ€” | 8k | -| Nemotron Nano VL 8B | C | โ€” | โ€” | โ€” | 128k | -| Gemma 3 12B | B | โ€” | 8.8 | 25 t/s | 33k | -| Gemma 3 4B | C | โ€” | 6.3 | 26.1 t/s | 33k | -| MiniMax M2.5 | S | โ€” | 41.9 | 92.8 t/s | 205k | -| GLiNER PII | C | โ€” | โ€” | โ€” | 33k | -| Nemotron 3 Super 120B | S | 60.5% | โ€” | 449.5 t/s | 262k | -| Qwen3.5 122B | S | โ€” | 41.6 | 156.2 t/s | 131k | -| Deepseek V4 Flash | S+ | โ€” | 46.5 | 79.3 t/s | 33k | -| Deepseek V4 Pro | S+ | โ€” | 51.5 | 35.7 t/s | 33k | -| Gemma 3 27b It | B | โ€” | 10.3 | 25.9 t/s | 33k | -| Gemma 4 31b It | S | โ€” | 39.2 | 35.5 t/s | 33k | -| Minimax M2.7 | S+ | โ€” | 49.6 | 45.5 t/s | 33k | -| Mistral Small 4 119b 2603 | ? | โ€” | โ€” | โ€” | 33k | -| Ising Calibration 1 35b A3b | ? | โ€” | โ€” | โ€” | 33k | +| Model | Tier | SWE | AA Intel | AA Speed | Context | +| -------------------------------------- | ---- | ----- | -------- | --------- | ------- | +| GLM 5 | S+ | 77.8% | 49.8 | 68.4 t/s | 128k | +| Kimi K2.5 | S+ | 76.8% | 46.8 | 33.8 t/s | 128k | +| GLM 4.7 | S+ | 73.8% | 42.1 | 93.3 t/s | 200k | +| Kimi K2 Thinking | S+ | 71.3% | 40.9 | 75.2 t/s | 256k | +| DeepSeek V3.2 | S+ | 73.1% | 32.1 | 80 t/s | 128k | +| Devstral 2 123B | S+ | 72.2% | 22 | 75.8 t/s | 256k | +| Qwen3 235B | S+ | 70.0% | โ€” | โ€” | 128k | +| Qwen3 Coder 480B | S+ | 70.6% | 24.8 | 69.9 t/s | 256k | +| Step 3.5 Flash | S+ | 74.4% | 38.5 | 140.5 t/s | 256k | +| MiniMax M2 | S | 69.4% | 36.1 | 89.9 t/s | 128k | +| GPT OSS 120B | S | 60.0% | 33.3 | 221 t/s | 131k | +| DeepSeek V3.1 Term. | S | 68.4% | 28.5 | 0 t/s | 128k | +| Kimi K2 0905 | A | โ€” | 26.3 | 36.3 t/s | 128k | +| Kimi K2 Instruct | S | 65.8% | 26.3 | 36.3 t/s | 128k | +| Llama 4 Maverick | S | 62.0% | 18.4 | 111 t/s | 1m | +| Qwen3 80B Instruct | S | 65.0% | 20.1 | 174.5 t/s | 128k | +| Qwen3 80B Thinking | S | 68.0% | โ€” | โ€” | 128k | +| Qwen3.5 400B | S | 68.0% | 45 | 50.9 t/s | 128k | +| Mistral Large 675B | A+ | 58.0% | 23 | 56 t/s | 256k | +| Nemotron Ultra 253B | A+ | 56.0% | โ€” | โ€” | 128k | +| GPT OSS 20B | A | 42.0% | 24.5 | 277.7 t/s | 131k | +| Mistral Medium 3 | A | 48.0% | 18.8 | 65.6 t/s | 128k | +| Magistral Small | A | 45.0% | 16.8 | 0 t/s | 32k | +| Llama 3.1 405B | A | 44.0% | โ€” | โ€” | 128k | +| Mistral Large 2 | A- | โ€” | 15.1 | 38.6 t/s | 128k | +| Mistral Nemotron | A | โ€” | โ€” | โ€” | 128k | +| Nemotron 4 340B | A | โ€” | โ€” | โ€” | 4k | +| Nemotron Nano 3 30B | A | โ€” | โ€” | โ€” | 128k | +| Nemotron Nano 30B | A | 43.0% | โ€” | โ€” | 128k | +| Nemotron Super 49B | A | 49.0% | 14.3 | 0 t/s | 128k | +| Nemotron Super 49B v1 | B+ | โ€” | 14.3 | 0 t/s | 128k | +| Qwen2.5 Coder 32B | A | 46.0% | 12.9 | 0 t/s | 32k | +| Llama 3.2 90B Vision | A- | โ€” | โ€” | โ€” | 128k | +| Llama 3.3 70B | A- | 39.5% | โ€” | โ€” | 128k | +| Mistral Large | B | โ€” | 9.9 | 0 t/s | 32k | +| Nemotron 51B | A- | โ€” | โ€” | โ€” | 128k | +| Nemotron 70B | A- | โ€” | โ€” | โ€” | 128k | +| Palmyra Creative 122B | A- | โ€” | โ€” | โ€” | 32k | +| Seed OSS 36B | A- | 38.0% | 25.2 | 42.4 t/s | 32k | +| Stockmark 100B | A- | 36.0% | โ€” | โ€” | 32k | +| ChatQA 1.5 70B | B+ | โ€” | โ€” | โ€” | 128k | +| Code Llama 70B | B+ | โ€” | โ€” | โ€” | 4k | +| Codestral 22B | B+ | โ€” | โ€” | โ€” | 32k | +| Dracarys 70B | B+ | โ€” | โ€” | โ€” | 128k | +| Granite 34B Code | B+ | 30.0% | โ€” | โ€” | 32k | +| Llama 3.1 70B | B+ | โ€” | โ€” | โ€” | 128k | +| Ministral 14B | B+ | 34.0% | โ€” | โ€” | 32k | +| Mixtral 8x22B | B+ | 32.0% | โ€” | โ€” | 64k | +| Palmyra Fin 70B | B+ | โ€” | โ€” | โ€” | 32k | +| Palmyra Med 70B | B+ | โ€” | โ€” | โ€” | 32k | +| Palmyra Med 70B 32K | B+ | โ€” | โ€” | โ€” | 32k | +| Yi Large | B+ | โ€” | โ€” | โ€” | 32k | +| Cosmos Reason2 8B | B | โ€” | โ€” | โ€” | 128k | +| Llama 2 70B | B | โ€” | โ€” | โ€” | 4k | +| Llama 3.1 8B | B | โ€” | โ€” | โ€” | 128k | +| Llama 3.2 11B Vision | B | โ€” | โ€” | โ€” | 128k | +| Mistral Nemo 12B | B | โ€” | โ€” | โ€” | 128k | +| Nemotron Nano 12B VL | B | โ€” | โ€” | โ€” | 128k | +| Nemotron Nano 9B v2 | B+ | โ€” | 13.2 | 152.7 t/s | 128k | +| Phi 3 Vision | B | โ€” | โ€” | โ€” | 128k | +| Phi 3.5 MoE | B | โ€” | โ€” | โ€” | 128k | +| Phi 4 Multimodal | B | โ€” | 10 | 16.9 t/s | 128k | +| Jamba 1.5 Large | B | โ€” | 10.7 | 0 t/s | 256k | +| Phi 4 Mini | C | 14.0% | 8.4 | 44.5 t/s | 128k | +| DBRX Instruct | B | โ€” | 8.3 | 0 t/s | 33k | +| Mixtral 8x7B | C | โ€” | 8 | โ€” | 33k | +| CodeGemma 7B | C | โ€” | โ€” | โ€” | 8k | +| DeepSeek Coder 6.7B | C | โ€” | โ€” | โ€” | 16k | +| Gemma 2 2B | C | โ€” | โ€” | โ€” | 8k | +| Gemma 3n 2B | C | โ€” | 4.8 | 60.9 t/s | 8k | +| Gemma 3n 4B | C | โ€” | 6.4 | 14.3 t/s | 8k | +| Granite 3.0 3B | C | โ€” | โ€” | โ€” | 128k | +| Granite 3.0 8B | C | โ€” | โ€” | โ€” | 128k | +| Granite 8B Code | C | โ€” | โ€” | โ€” | 8k | +| Llama 3.2 1B | C | โ€” | โ€” | โ€” | 128k | +| Llama 3.2 3B | C | โ€” | โ€” | โ€” | 128k | +| Minitron 8B | C | โ€” | โ€” | โ€” | 8k | +| Mistral 7B v0.3 | C | โ€” | โ€” | โ€” | 32k | +| Nemotron Mini 4B | C | โ€” | โ€” | โ€” | 4k | +| Nemotron Nano 8B | C | โ€” | โ€” | โ€” | 128k | +| Sarvam M | C | โ€” | โ€” | โ€” | 8k | +| SEA-LION 7B | C | โ€” | โ€” | โ€” | 4k | +| Solar 10.7B | C | โ€” | โ€” | โ€” | 4k | +| Zamba2 7B | C | โ€” | โ€” | โ€” | 8k | +| Nemotron Nano VL 8B | C | โ€” | โ€” | โ€” | 128k | +| Gemma 3 12B | B | โ€” | 8.8 | 25 t/s | 33k | +| Gemma 3 4B | C | โ€” | 6.3 | 26.1 t/s | 33k | +| MiniMax M2.5 | S | โ€” | 41.9 | 92.8 t/s | 205k | +| GLiNER PII | C | โ€” | โ€” | โ€” | 33k | +| Nemotron 3 Super 120B | S | 60.5% | โ€” | 449.5 t/s | 262k | +| Qwen3.5 122B | S | โ€” | 41.6 | 156.2 t/s | 131k | +| Deepseek V4 Flash | S+ | โ€” | 46.5 | 79.3 t/s | 33k | +| Deepseek V4 Pro | S+ | โ€” | 51.5 | 35.7 t/s | 33k | +| Gemma 3 27b It | B | โ€” | 10.3 | 25.9 t/s | 33k | +| Gemma 4 31b It | S | โ€” | 39.2 | 35.5 t/s | 33k | +| Minimax M2.7 | S+ | โ€” | 49.6 | 45.5 t/s | 33k | +| Mistral Small 4 119b 2603 | ? | โ€” | โ€” | โ€” | 33k | +| Ising Calibration 1 35b A3b | ? | โ€” | โ€” | โ€” | 33k | | Nemotron 3 Nano Omni 30b A3b Reasoning | ? | โ€” | โ€” | โ€” | 33k | -| Glm 5.1 | S+ | โ€” | 51.4 | 50.5 t/s | 33k | +| Glm 5.1 | S+ | โ€” | 51.4 | 50.5 t/s | 33k | ## Free OpenRouter Models **24 models** ยท Free tier via [openrouter.ai](https://openrouter.ai) -| Model | Tier | SWE | AA Intel | AA Speed | Context | -| ----- | ---- | --- | -------- | -------- | ------- | -| Qwen3 Coder 480B | A+ | โ€” | โ€” | โ€” | 262k | -| Qwen3 80B Instruct | A | โ€” | 20.1 | 174.5 t/s | 262k | -| Llama 3.3 70B | A- | โ€” | โ€” | โ€” | 128k | -| GPT OSS 120B | A+ | โ€” | 33.3 | 221 t/s | 131k | -| Nemotron Nano 30B | B+ | โ€” | โ€” | โ€” | 256k | -| GPT OSS 20B | A | โ€” | 24.5 | 277.7 t/s | 131k | -| GLM 4.5 Air | A | โ€” | 23.2 | 60 t/s | 128k | -| Nemotron Nano 9B | B | โ€” | โ€” | โ€” | 128k | -| NVIDIA: Nemotron 3 Super (free) | S | 60.5% | 36 | 449.5 t/s | 262k | -| inclusionAI: Ling-2.6-1T (free) | A+ | โ€” | 33.6 | 0 t/s | 262k | -| inclusionAI: Ling-2.6-flash (free) | A | โ€” | 26.2 | 206.6 t/s | 262k | -| MiniMax: MiniMax M2.5 (free) | S | โ€” | 41.9 | 92.8 t/s | 197k | -| Poolside: Laguna M.1 (free) | ? | โ€” | โ€” | โ€” | 131k | -| Poolside: Laguna XS.2 (free) | ? | โ€” | โ€” | โ€” | 131k | -| Tencent: Hy3 preview (free) | S | โ€” | 41.9 | 84.5 t/s | 262k | -| Venice: Uncensored (free) | ? | โ€” | โ€” | โ€” | 33k | -| Google: Gemma 3 12B (free) | B | โ€” | 8.8 | 25 t/s | 33k | -| Google: Gemma 3 27B (free) | B | โ€” | 10.3 | 25.9 t/s | 131k | -| Google: Gemma 3 4B (free) | C | โ€” | 6.3 | 26.1 t/s | 33k | -| Google: Gemma 3n 2B (free) | C | โ€” | 4.8 | 60.9 t/s | 8k | +| Model | Tier | SWE | AA Intel | AA Speed | Context | +| ------------------------------------- | ---- | ----- | -------- | --------- | ------- | +| Qwen3 Coder 480B | A+ | โ€” | โ€” | โ€” | 262k | +| Qwen3 80B Instruct | A | โ€” | 20.1 | 174.5 t/s | 262k | +| Llama 3.3 70B | A- | โ€” | โ€” | โ€” | 128k | +| GPT OSS 120B | A+ | โ€” | 33.3 | 221 t/s | 131k | +| Nemotron Nano 30B | B+ | โ€” | โ€” | โ€” | 256k | +| GPT OSS 20B | A | โ€” | 24.5 | 277.7 t/s | 131k | +| GLM 4.5 Air | A | โ€” | 23.2 | 60 t/s | 128k | +| Nemotron Nano 9B | B | โ€” | โ€” | โ€” | 128k | +| NVIDIA: Nemotron 3 Super (free) | S | 60.5% | 36 | 449.5 t/s | 262k | +| inclusionAI: Ling-2.6-1T (free) | A+ | โ€” | 33.6 | 0 t/s | 262k | +| inclusionAI: Ling-2.6-flash (free) | A | โ€” | 26.2 | 206.6 t/s | 262k | +| MiniMax: MiniMax M2.5 (free) | S | โ€” | 41.9 | 92.8 t/s | 197k | +| Poolside: Laguna M.1 (free) | ? | โ€” | โ€” | โ€” | 131k | +| Poolside: Laguna XS.2 (free) | ? | โ€” | โ€” | โ€” | 131k | +| Tencent: Hy3 preview (free) | S | โ€” | 41.9 | 84.5 t/s | 262k | +| Venice: Uncensored (free) | ? | โ€” | โ€” | โ€” | 33k | +| Google: Gemma 3 12B (free) | B | โ€” | 8.8 | 25 t/s | 33k | +| Google: Gemma 3 27B (free) | B | โ€” | 10.3 | 25.9 t/s | 131k | +| Google: Gemma 3 4B (free) | C | โ€” | 6.3 | 26.1 t/s | 33k | +| Google: Gemma 3n 2B (free) | C | โ€” | 4.8 | 60.9 t/s | 8k | | LiquidAI: LFM2.5-1.2B-Instruct (free) | B | โ€” | 8 | 0 t/s | 33k | | LiquidAI: LFM2.5-1.2B-Thinking (free) | B | โ€” | 8.1 | 0 t/s | 33k | -| Meta: Llama 3.2 3B Instruct (free) | B | โ€” | 9.7 | 51.8 t/s | 131k | -| Nous: Hermes 3 405B Instruct (free) | A | โ€” | 18.6 | 34.2 t/s | 131k | +| Meta: Llama 3.2 3B Instruct (free) | B | โ€” | 9.7 | 51.8 t/s | 131k | +| Nous: Hermes 3 405B Instruct (free) | A | โ€” | 18.6 | 34.2 t/s | 131k | diff --git a/publish/frouter-cli-shim/bin.js b/publish/frouter-cli-shim/bin.js index eaf6133..69bc800 100644 --- a/publish/frouter-cli-shim/bin.js +++ b/publish/frouter-cli-shim/bin.js @@ -27,13 +27,19 @@ try { process.stderr.write( `\x1b[31m Failed to locate ${NEW_PACKAGE}. Reinstall manually: npm install -g ${NEW_PACKAGE}${RESET}\n`, ); - process.stderr.write(`${DIM} ${err && err.message ? err.message : String(err)}${RESET}\n`); + process.stderr.write( + `${DIM} ${err && err.message ? err.message : String(err)}${RESET}\n`, + ); process.exit(1); } -const child = spawn(process.execPath, [freeRouterBin, ...process.argv.slice(2)], { - stdio: "inherit", -}); +const child = spawn( + process.execPath, + [freeRouterBin, ...process.argv.slice(2)], + { + stdio: "inherit", + }, +); child.on("exit", (code, signal) => { if (signal) { @@ -44,6 +50,8 @@ child.on("exit", (code, signal) => { }); child.on("error", (err) => { - process.stderr.write(`\x1b[31m free-router failed to start: ${err.message}${RESET}\n`); + process.stderr.write( + `\x1b[31m free-router failed to start: ${err.message}${RESET}\n`, + ); process.exit(1); }); diff --git a/publish/legacy-scope-shim/bin.js b/publish/legacy-scope-shim/bin.js index dc5d5e3..75198a6 100644 --- a/publish/legacy-scope-shim/bin.js +++ b/publish/legacy-scope-shim/bin.js @@ -27,13 +27,19 @@ try { process.stderr.write( `\x1b[31m Failed to locate ${NEW_PACKAGE}. Reinstall manually: npm install -g ${NEW_PACKAGE}${RESET}\n`, ); - process.stderr.write(`${DIM} ${err && err.message ? err.message : String(err)}${RESET}\n`); + process.stderr.write( + `${DIM} ${err && err.message ? err.message : String(err)}${RESET}\n`, + ); process.exit(1); } -const child = spawn(process.execPath, [freeRouterBin, ...process.argv.slice(2)], { - stdio: "inherit", -}); +const child = spawn( + process.execPath, + [freeRouterBin, ...process.argv.slice(2)], + { + stdio: "inherit", + }, +); child.on("exit", (code, signal) => { if (signal) { @@ -44,6 +50,8 @@ child.on("exit", (code, signal) => { }); child.on("error", (err) => { - process.stderr.write(`\x1b[31m free-router failed to start: ${err.message}${RESET}\n`); + process.stderr.write( + `\x1b[31m free-router failed to start: ${err.message}${RESET}\n`, + ); process.exit(1); }); diff --git a/scripts/update-models.ts b/scripts/update-models.ts index e153d06..7ffb571 100644 --- a/scripts/update-models.ts +++ b/scripts/update-models.ts @@ -1015,8 +1015,7 @@ async function main() { console.log("Fetching OpenCode-supported models (Models.dev)..."); let support = loadExistingSupportFile(); let supportFetched = false; - let supportSource: "models.dev" | "opencode-github" | "existing" = - "existing"; + let supportSource: "models.dev" | "opencode-github" | "existing" = "existing"; try { const modelsDev = await fetchJson("models.dev", "/api.json"); @@ -1072,10 +1071,7 @@ async function main() { console.log("\n(dry run โ€” pass --apply to write OpenCode support data)"); } else { console.log("\nโ•โ•โ• APPLYING OPENCODE SUPPORT ONLY โ•โ•โ•"); - const supportChanged = applyOpenCodeSupportToRankings( - rankings, - support, - ); + const supportChanged = applyOpenCodeSupportToRankings(rankings, support); if (supportChanged) { writeFileSync(RANKINGS_PATH, JSON.stringify(rankings, null, 2) + "\n"); report.rankings.changed = true; @@ -1157,7 +1153,9 @@ async function main() { orFetchOk = true; report.providers.openrouter.fetched = true; report.providers.openrouter.total = orApiModels.length; - console.log(` Found ${orApiModels.length} OpenRouter free LLM/VLM models\n`); + console.log( + ` Found ${orApiModels.length} OpenRouter free LLM/VLM models\n`, + ); } catch (err: any) { console.error(` Failed to fetch OpenRouter: ${err.message}\n`); } @@ -1220,9 +1218,7 @@ async function main() { report.providers.openrouter.new_rankings = orNew.length; report.providers.openrouter.removed_rankings = orRemoved.length; report.added_models.push(...orNew.map((m) => `openrouter/${m.id}`)); - report.removed_models.push( - ...orRemoved.map((id) => `openrouter/${id}`), - ); + report.removed_models.push(...orRemoved.map((id) => `openrouter/${id}`)); console.log("\nโ•โ•โ• OPENROUTER DIFF โ•โ•โ•"); if (!orFetchOk) { @@ -1462,7 +1458,8 @@ async function main() { aaHit?.aa_benchmark_score ?? nimTwin?.aa_benchmark_score ?? null, aa_benchmark_name: aaHit?.aa_benchmark_name ?? nimTwin?.aa_benchmark_name ?? null, - aa_coding_index: aaHit?.aa_coding_index ?? nimTwin?.aa_coding_index ?? null, + aa_coding_index: + aaHit?.aa_coding_index ?? nimTwin?.aa_coding_index ?? null, aa_intelligence: aaHit?.aa_intelligence ?? nimTwin?.aa_intelligence ?? null, aa_speed_tps: aaHit?.aa_speed_tps ?? nimTwin?.aa_speed_tps ?? null, diff --git a/src/bin/free-router.ts b/src/bin/free-router.ts index 23ee4a1..ad10b9a 100755 --- a/src/bin/free-router.ts +++ b/src/bin/free-router.ts @@ -425,18 +425,23 @@ function blockWidthLines( const c = cols(); const guard = WRAP_GUARD_COLS; const maxW = Math.max(0, c - guard); - if (maxW <= 4) return [fullWidthLine(""), fullWidthLine(""), fullWidthLine("")]; + if (maxW <= 4) + return [fullWidthLine(""), fullWidthLine(""), fullWidthLine("")]; const innerW = maxW - 2; const rightPart = right ? truncAnsi(right, innerW) : ""; - const leftMaxW = Math.max(0, innerW - visLen(rightPart) - (rightPart ? 1 : 0)); + const leftMaxW = Math.max( + 0, + innerW - visLen(rightPart) - (rightPart ? 1 : 0), + ); const leftPart = truncAnsi(left, leftMaxW); const gapW = Math.max(0, innerW - visLen(leftPart) - visLen(rightPart)); const top = `${borderStyle}โ•ญ${"โ”€".repeat(Math.max(0, innerW))}โ•ฎ${R}`; const middle = `${borderStyle}โ”‚${R}${leftPart}${" ".repeat(gapW)}${rightPart}${borderStyle}โ”‚${R}`; const bottom = `${borderStyle}โ•ฐ${"โ”€".repeat(Math.max(0, innerW))}โ•ฏ${R}`; - return [top, middle, bottom].map((line) => - `${line}${" ".repeat(Math.max(0, maxW - visLen(line)))}${R}${CLEAR_TO_EOL}`, + return [top, middle, bottom].map( + (line) => + `${line}${" ".repeat(Math.max(0, maxW - visLen(line)))}${R}${CLEAR_TO_EOL}`, ); } @@ -461,7 +466,9 @@ function tableLine(cells: string[], separatorStyle = ""): string { function tableHeaderLine(): string { const cells = TABLE_COLUMNS.map((col) => tableCell( - col.sortCol ? colHdr(col.label, col.sortCol, col.width, col.right) : col.label, + col.sortCol + ? colHdr(col.label, col.sortCol, col.width, col.right) + : col.label, col.width, `${BG_TABLE_HDR}${WHITE}${B}`, col.right, @@ -471,7 +478,9 @@ function tableHeaderLine(): string { } function tableSeparatorLine(): string { - return fullWidthLine(`${D}${"โ”€".repeat(Math.max(0, cols() - WRAP_GUARD_COLS))}${R}`); + return fullWidthLine( + `${D}${"โ”€".repeat(Math.max(0, cols() - WRAP_GUARD_COLS))}${R}`, + ); } function tableRowStyle(selected: boolean): string { @@ -491,8 +500,10 @@ function tableRowLine( function formatVerdict(verdict: string, selected: boolean): string { const rowStyle = tableRowStyle(selected); - if (verdict.startsWith("โœ“ ")) return `${GREEN}โœ“${R}${rowStyle}${verdict.slice(1)}`; - if (verdict.startsWith("x ")) return `${RED}x${R}${rowStyle}${verdict.slice(1)}`; + if (verdict.startsWith("โœ“ ")) + return `${GREEN}โœ“${R}${rowStyle}${verdict.slice(1)}`; + if (verdict.startsWith("x ")) + return `${RED}x${R}${rowStyle}${verdict.slice(1)}`; return verdict; } @@ -676,7 +687,10 @@ function modeTagLine(label: string): string { return fullWidthLine(`${BG_OFF}${WHITE} ${label} ${R}`); } -function modalFooterLine(items: Array<[string, string]>, lastLine = true): string { +function modalFooterLine( + items: Array<[string, string]>, + lastLine = true, +): string { return fullWidthLine( ` ${items.map(([key, label]) => footerKey(key, label)).join(" ")} `, lastLine, @@ -739,8 +753,9 @@ function renderMain() { for (let i = 0; i < rowsAvailable; i++) { if (filtered.length === 0) { out += - (i === 0 ? centeredWidthLine(`${D}not found${R}`) : fullWidthLine("")) + - "\n"; + (i === 0 + ? centeredWidthLine(`${D}not found${R}`) + : fullWidthLine("")) + "\n"; continue; } @@ -969,8 +984,10 @@ function clampCursor(next: number) { function resolveUserScrollSortPauseMs(cfg: FrouterConfig): number { // Env overrides config so users can tune behavior per terminal/session. const raw = - readEnv("FREE_ROUTER_SCROLL_SORT_PAUSE_MS", "FROUTER_SCROLL_SORT_PAUSE_MS") ?? - cfg?.ui?.scrollSortPauseMs; + readEnv( + "FREE_ROUTER_SCROLL_SORT_PAUSE_MS", + "FROUTER_SCROLL_SORT_PAUSE_MS", + ) ?? cfg?.ui?.scrollSortPauseMs; if (raw == null || raw === "") return DEFAULT_USER_SCROLL_SORT_PAUSE_MS; const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed < 0) { @@ -1111,9 +1128,7 @@ async function applySelectionToTarget(targetId: TargetId, launch: boolean) { return; } if (notice) w(`\n${notice}\n`); - w( - `${GREEN} โœ“ Wrote ${targetLabel(targetId)} config: ${writtenPath}${R}\n`, - ); + w(`${GREEN} โœ“ Wrote ${targetLabel(targetId)} config: ${writtenPath}${R}\n`); // Guard: missing API key โ†’ offer to add it if (shouldLaunch && !targetApiKey) { @@ -1131,9 +1146,7 @@ async function applySelectionToTarget(targetId: TargetId, launch: boolean) { openApiKeyEditorFromMain(targetPk); return; } - w( - `${YELLOW} Launch cancelled. Set ${envVar} with A, then retry.${R}\n`, - ); + w(`${YELLOW} Launch cancelled. Set ${envVar} with A, then retry.${R}\n`); shouldLaunch = false; } @@ -1166,7 +1179,9 @@ async function applySelectionToTarget(targetId: TargetId, launch: boolean) { } function targetLabel(targetId: TargetId) { - return CONFIG_TARGETS.find((target) => target.id === targetId)?.label ?? targetId; + return ( + CONFIG_TARGETS.find((target) => target.id === targetId)?.label ?? targetId + ); } function resolveTargetApplySelection(selectedModel: Model, targetId: TargetId) { @@ -1974,7 +1989,8 @@ async function runUpdateApp( } async function checkForUpdate(): Promise { - if (readEnv(UPDATE_SKIP_ONCE_ENV, LEGACY_UPDATE_SKIP_ONCE_ENV) === "1") return; + if (readEnv(UPDATE_SKIP_ONCE_ENV, LEGACY_UPDATE_SKIP_ONCE_ENV) === "1") + return; const latest = await fetchLatestVersion(); if (!latest || !isStrictlyNewerVersion(PKG_VERSION, latest)) return; diff --git a/src/lib/config.ts b/src/lib/config.ts index bafe1fd..1b7d84f 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -111,7 +111,8 @@ export function loadConfig(): FrouterConfig { }, }; if (existsSync(CONFIG_PATH)) return readConfigFile(CONFIG_PATH, defaults); - if (existsSync(LEGACY_CONFIG_PATH)) return migrateLegacyConfigIfNeeded(defaults); + if (existsSync(LEGACY_CONFIG_PATH)) + return migrateLegacyConfigIfNeeded(defaults); return defaults; } diff --git a/src/lib/models.ts b/src/lib/models.ts index 94839b7..63353d7 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -905,7 +905,9 @@ async function fetchNimModels(apiKey: string | null): Promise { ); if (!data) return null; - const allowedIds = new Set(getRankingsBySource("nim").map((entry) => entry.model_id)); + const allowedIds = new Set( + getRankingsBySource("nim").map((entry) => entry.model_id), + ); const result = data .filter((m) => { @@ -941,7 +943,8 @@ async function fetchOpenRouterModels(apiKey: string | null): Promise { ); return data .filter((m) => { - if (m?.pricing?.prompt !== "0" || m?.pricing?.completion !== "0") return false; + if (m?.pricing?.prompt !== "0" || m?.pricing?.completion !== "0") + return false; const output = Array.isArray(m?.architecture?.output_modalities) ? m.architecture.output_modalities : []; @@ -950,10 +953,13 @@ async function fetchOpenRouterModels(apiKey: string | null): Promise { const input = Array.isArray(m?.architecture?.input_modalities) ? m.architecture.input_modalities : ["text"]; - if (input.some((value: string) => value !== "text" && value !== "image")) { + if ( + input.some((value: string) => value !== "text" && value !== "image") + ) { return false; } - const haystack = `${m?.id || ""} ${m?.name || ""} ${m?.description || ""}`.toLowerCase(); + const haystack = + `${m?.id || ""} ${m?.name || ""} ${m?.description || ""}`.toLowerCase(); if (haystack.includes("openrouter/free")) return false; return !/ocr|video|audio|speech|voice|speaker|detector|detection|translate|translation|embed|rerank|guard|safety|retriever/i.test( haystack, diff --git a/src/lib/targets.ts b/src/lib/targets.ts index ea6a200..6d2c1b7 100644 --- a/src/lib/targets.ts +++ b/src/lib/targets.ts @@ -174,7 +174,9 @@ function envLineValue(value: string) { function setEnvFileValue(path: string, key: string, value: string) { const dir = dirname(path); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const lines = existsSync(path) ? readFileSync(path, "utf8").split(/\r?\n/) : []; + const lines = existsSync(path) + ? readFileSync(path, "utf8").split(/\r?\n/) + : []; const nextLine = `${key}=${envLineValue(value)}`; let replaced = false; const nextLines = lines.map((line) => { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ca5eb78..e7fe521 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -13,7 +13,9 @@ export const ORANGE = "\x1b[38;5;208m"; export const BG_SEL = "\x1b[48;5;235m"; // subtle selection highlight export function readEnv(name: string, legacyName?: string): string | undefined { - return process.env[name] ?? (legacyName ? process.env[legacyName] : undefined); + return ( + process.env[name] ?? (legacyName ? process.env[legacyName] : undefined) + ); } // โ”€โ”€โ”€ Shared types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ From 44cef8f6546d4e66390c56464ad7841484d9c725 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 19:41:10 +0900 Subject: [PATCH 11/11] fix: make dist data copy cwd-independent --- scripts/copy-dist-data.mjs | 8 ++++++-- tests/copy-dist-data.test.mjs | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/scripts/copy-dist-data.mjs b/scripts/copy-dist-data.mjs index 6caf5f6..898282c 100644 --- a/scripts/copy-dist-data.mjs +++ b/scripts/copy-dist-data.mjs @@ -1,10 +1,14 @@ import { copyFile, mkdir } from "node:fs/promises"; const distDataFiles = ["model-rankings.json", "model-support.json"]; +const rootDir = new URL("../", import.meta.url); -await mkdir("dist", { recursive: true }); +await mkdir(new URL("dist/", rootDir), { recursive: true }); await Promise.all( distDataFiles.map((dataFileName) => - copyFile(`data/${dataFileName}`, `dist/${dataFileName}`), + copyFile( + new URL(`data/${dataFileName}`, rootDir), + new URL(`dist/${dataFileName}`, rootDir), + ), ), ); diff --git a/tests/copy-dist-data.test.mjs b/tests/copy-dist-data.test.mjs index 0010a96..5db88ff 100644 --- a/tests/copy-dist-data.test.mjs +++ b/tests/copy-dist-data.test.mjs @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; const distDataFiles = ["model-rankings.json", "model-support.json"]; @@ -16,3 +18,16 @@ test("copy-dist-data mirrors published data files", async () => { }), ); }); + +test("copy-dist-data works outside the repo cwd", async () => { + const originalCwd = process.cwd(); + const otherCwd = await mkdtemp(join(tmpdir(), "copy-dist-data-")); + + try { + process.chdir(otherCwd); + await import(`../scripts/copy-dist-data.mjs?cwd=${Date.now()}`); + } finally { + process.chdir(originalCwd); + await rm(otherCwd, { recursive: true, force: true }); + } +});