From ded2ea7d3ed3eafaafdf729dd2a115ce16e506f6 Mon Sep 17 00:00:00 2001 From: GarlicRot Date: Fri, 23 Oct 2026 06:37:55 +0000 Subject: [PATCH 01/11] chore(dev): add isolated Discord command registration --- scripts/deploy-dev.js | 85 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 scripts/deploy-dev.js diff --git a/scripts/deploy-dev.js b/scripts/deploy-dev.js new file mode 100644 index 0000000..b413cd1 --- /dev/null +++ b/scripts/deploy-dev.js @@ -0,0 +1,85 @@ +require("dotenv").config(); + +const fs = require("node:fs"); +const path = require("node:path"); +const { REST, Routes } = require("discord.js"); + +const PROD_ID = "1402508845582123128"; +const { NODE_ENV, CLIENT_ID, GUILD_ID, DISCORD_TOKEN } = process.env; + +function fail(message) { + throw new Error(message); +} + +function collectCommands(directory) { + const commands = []; + + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const fullPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + commands.push(...collectCommands(fullPath)); + } else if (entry.name.endsWith(".js")) { + const command = require(fullPath); + + if (command?.data && typeof command.execute === "function") { + commands.push(command.data.toJSON()); + } + } + } + + return commands; +} + +async function main() { + if (NODE_ENV !== "development") { + fail("Development mode is required."); + } + + if (!DISCORD_TOKEN || !CLIENT_ID || !GUILD_ID) { + fail("Missing development Discord configuration."); + } + + if (!/^\d{17,20}$/.test(CLIENT_ID) || + !/^\d{17,20}$/.test(GUILD_ID)) { + fail("Invalid Discord application or server ID."); + } + + if (CLIENT_ID !== "1563069074236383314" || CLIENT_ID === PROD_ID) { + fail("Production application is protected."); + } + + const rest = new REST({ version: "10" }).setToken(DISCORD_TOKEN); + + const application = await rest.get(Routes.currentApplication()); + + if (application.id !== CLIENT_ID || application.id === PROD_ID) { + fail("Token does not belong to the development application."); + } + + const guild = await rest.get(Routes.guild(GUILD_ID)); + + if (guild.id !== "862154341079056464") { + fail("Target server is not SaFe SpAcE."); + } + + const commands = collectCommands( + path.join(__dirname, "../src/commands") + ); + + console.log(`Application: ${application.name}`); + console.log(`Test server: ${guild.name}`); + console.log(`Commands: ${commands.length}`); + + await rest.put( + Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID), + { body: commands } + ); + + console.log("Development commands registered successfully."); +} + +main().catch((error) => { + console.error("Registration failed:", error.message); + process.exitCode = 1; +}); From 586205c38b843c0d2ff6031290c58e3eb5edf622 Mon Sep 17 00:00:00 2001 From: GarlicRot Date: Fri, 23 Oct 2026 06:49:09 +0000 Subject: [PATCH 02/11] fix: secure dependencies and improve GitHub API reliability --- package-lock.json | 224 +++++++++++++++++++++++------------- src/utils/getRepoDetails.js | 128 +++++++++++++++------ 2 files changed, 236 insertions(+), 116 deletions(-) diff --git a/package-lock.json b/package-lock.json index 674b431..c9e72c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,15 +19,15 @@ } }, "node_modules/@discordjs/builders": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.11.2.tgz", - "integrity": "sha512-F1WTABdd8/R9D1icJzajC4IuLyyS8f3rTOz66JsSI3pKvpCAtsMBweu8cyNYsIyvcrKAVn9EPK+Psoymq+XC0A==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", "license": "Apache-2.0", "dependencies": { - "@discordjs/formatters": "^0.6.1", - "@discordjs/util": "^1.1.1", + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.1", + "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -49,12 +49,12 @@ } }, "node_modules/@discordjs/formatters": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.1.tgz", - "integrity": "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", "license": "Apache-2.0", "dependencies": { - "discord-api-types": "^0.38.1" + "discord-api-types": "^0.38.33" }, "engines": { "node": ">=16.11.0" @@ -64,20 +64,20 @@ } }, "node_modules/@discordjs/rest": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.5.1.tgz", - "integrity": "sha512-Tg9840IneBcbrAjcGaQzHUJWFNq1MMWZjTdjJ0WS/89IffaNKc++iOvffucPxQTF/gviO9+9r8kEPea1X5J2Dw==", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.3.tgz", + "integrity": "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==", "license": "Apache-2.0", "dependencies": { "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.1.1", + "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.3", + "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.1", - "magic-bytes.js": "^1.10.0", + "discord-api-types": "^0.38.50", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "^6.27.0" }, "engines": { "node": ">=18" @@ -99,10 +99,13 @@ } }, "node_modules/@discordjs/util": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz", - "integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, "engines": { "node": ">=18" }, @@ -169,9 +172,9 @@ } }, "node_modules/@sapphire/snowflake": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", - "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", "license": "MIT", "engines": { "node": ">=v14.0.0", @@ -206,6 +209,18 @@ "npm": ">=7.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -234,14 +249,15 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/call-bind-apply-helpers": { @@ -303,6 +319,23 @@ "node": ">= 0.8" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -313,33 +346,30 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.18", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.18.tgz", - "integrity": "sha512-ygenySjZKUaBf5JT8BNhZSxLzwpwdp41O0wVroOTu/N2DxFH7dxYTZUSnFJ6v+/2F3BMcnD47PC47u4aLOLxrQ==", - "license": "MIT", - "workspaces": [ - "scripts/actions/documentation" - ] + "version": "0.38.57", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.57.tgz", + "integrity": "sha512-R1CTSSt4RshqCq23j2Xq2ZscvzB4LzG9OB1CYQdKUGdw8Ti/oF/72EEtK2mNpagG/Vj9NcBT7dw9lKLf+Dacfg==", + "license": "MIT" }, "node_modules/discord.js": { - "version": "14.21.0", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.21.0.tgz", - "integrity": "sha512-U5w41cEmcnSfwKYlLv5RJjB8Joa+QJyRwIJz5i/eg+v2Qvv6EYpCRhN9I2Rlf0900LuqSDg8edakUATrDZQncQ==", + "version": "14.27.0", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.27.0.tgz", + "integrity": "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==", "license": "Apache-2.0", "dependencies": { - "@discordjs/builders": "^1.11.2", + "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", - "@discordjs/formatters": "^0.6.1", - "@discordjs/rest": "^2.5.1", - "@discordjs/util": "^1.1.1", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.2", + "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", - "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.1", + "@sapphire/snowflake": "3.5.5", + "discord-api-types": "^0.38.49", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.10.0", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "^6.27.0" }, "engines": { "node": ">=18" @@ -393,9 +423,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -426,9 +456,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", @@ -446,16 +476,16 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -565,9 +595,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -576,10 +606,33 @@ "node": ">= 0.4" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -589,9 +642,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.snakecase": { @@ -601,9 +654,9 @@ "license": "MIT" }, "node_modules/magic-bytes.js": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", - "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.1.tgz", + "integrity": "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw==", "license": "MIT" }, "node_modules/math-intrinsics": { @@ -636,6 +689,12 @@ "node": ">= 0.6" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -657,10 +716,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/supports-color": { "version": "7.2.0", @@ -693,9 +755,9 @@ "license": "0BSD" }, "node_modules/undici": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", - "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -724,9 +786,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/src/utils/getRepoDetails.js b/src/utils/getRepoDetails.js index fdce822..318482b 100644 --- a/src/utils/getRepoDetails.js +++ b/src/utils/getRepoDetails.js @@ -1,50 +1,108 @@ const axios = require("axios"); const GITHUB_API = "https://api.github.com"; +const CACHE_TTL = 10 * 60 * 1000; +const cache = new Map(); async function getRepoDetails(repoSlug) { + if ( + typeof repoSlug !== "string" || + !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repoSlug) + ) { + return {}; + } + + const key = repoSlug.toLowerCase(); + const cached = cache.get(key); + + if (cached && cached.expires > Date.now()) { + return cached.data; + } + const headers = { - Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, "User-Agent": "RusherPluginBot", + "Accept": "application/vnd.github+json", }; - const repoUrl = `${GITHUB_API}/repos/${repoSlug}`; - const releasesUrl = `${repoUrl}/releases?per_page=100`; // up to 100 releases - const commitsUrl = `${repoUrl}/commits?per_page=1`; - - try { - const [repoRes, releasesRes, commitRes] = await Promise.all([ - axios.get(repoUrl, { headers }), - axios.get(releasesUrl, { headers }), - axios.get(commitsUrl, { headers }), - ]); - - const stars = repoRes.data.stargazers_count || 0; - const lastCommit = commitRes.data[0]?.commit?.committer?.date || null; - - // Sum download counts from all releases - let totalDownloads = 0; - for (const release of releasesRes.data) { - if (release.assets && release.assets.length > 0) { - for (const asset of release.assets) { - totalDownloads += asset.download_count || 0; - } - } + const token = process.env.GITHUB_TOKEN?.trim(); + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const config = { + headers, + timeout: 7000, + }; + + const base = `${GITHUB_API}/repos/${repoSlug}`; + + const requests = [ + axios.get(base, config), + axios.get(`${base}/releases?per_page=100`, config), + axios.get(`${base}/commits?per_page=1`, config), + ]; + + const results = await Promise.allSettled(requests); + const stats = {}; + + const [repo, releases, commits] = results; + + if (repo.status === "fulfilled") { + const stars = repo.value.data.stargazers_count; + if (Number.isFinite(stars)) { + stats.stars = stars; } + } - return { - stars, - lastCommit, - downloadCount: totalDownloads, - }; - } catch (err) { - console.error(`GitHub API error for ${repoSlug}:`, err.message); - return { - stars: 0, - lastCommit: null, - downloadCount: 0, - }; + if ( + releases.status === "fulfilled" && + Array.isArray(releases.value.data) + ) { + const list = releases.value.data; + + // Avoid claiming a complete total when releases + // extend beyond the first API page. + if (list.length < 100) { + stats.downloadCount = list.reduce( + (total, release) => + total + (release.assets || []).reduce( + (sum, asset) => sum + (asset.download_count || 0), + 0 + ), + 0 + ); + } } + + if (commits.status === "fulfilled") { + const date = + commits.value.data[0]?.commit?.committer?.date; + + if (date) { + stats.lastCommit = date; + } + } + + const labels = ["repository", "releases", "commits"]; + + results.forEach((result, index) => { + if (result.status === "rejected") { + const error = result.reason; + console.warn( + `[GitHub] ${repoSlug} ${labels[index]} failed: ` + + (error.response?.status || error.message) + ); + } + }); + + cache.set(key, { + data: stats, + expires: Date.now() + + (Object.keys(stats).length ? CACHE_TTL : 30000), + }); + + return stats; } module.exports = { getRepoDetails }; From b3df3d78b84f2e2a7a614e3928284ee30eddde80 Mon Sep 17 00:00:00 2001 From: GarlicRot Date: Fri, 23 Oct 2026 07:01:08 +0000 Subject: [PATCH 03/11] fix(registry): preserve valid data and improve refresh recovery --- src/commands/dev/refresh.js | 21 ++- src/utils/dataStore.js | 258 +++++++++++++++++++++++++----------- test/dataStore.test.js | 188 ++++++++++++++++++++++++++ 3 files changed, 383 insertions(+), 84 deletions(-) create mode 100644 test/dataStore.test.js diff --git a/src/commands/dev/refresh.js b/src/commands/dev/refresh.js index 6dffef8..7010a74 100644 --- a/src/commands/dev/refresh.js +++ b/src/commands/dev/refresh.js @@ -24,10 +24,23 @@ module.exports = { } try { - await loadPluginData(); - await interaction.editReply( - "✅ Plugin and theme data refreshed from GitHub." - ); + const result = await loadPluginData(); + + const counts = `${result.plugins} plugins, ${result.themes} themes`; + + let message; + + if (result.source === "github") { + message = result.cacheUpdated + ? `✅ Registry refreshed from GitHub (${counts}).` + : `⚠️ Registry refreshed (${counts}), but cache saving failed.`; + } else if (result.source === "memory") { + message = `⚠️ GitHub refresh failed. Keeping existing data (${counts}).`; + } else { + message = `⚠️ GitHub unavailable. Using cached data (${counts}).`; + } + + await interaction.editReply(message); } catch (err) { console.error("Refresh failed:", err); await interaction.editReply("❌ Failed to refresh plugin/theme data."); diff --git a/src/utils/dataStore.js b/src/utils/dataStore.js index dbb2cfe..1205259 100644 --- a/src/utils/dataStore.js +++ b/src/utils/dataStore.js @@ -1,108 +1,206 @@ -// src/utils/dataStore.js const fetch = require("node-fetch"); const yaml = require("js-yaml"); -const fs = require("fs"); -const path = require("path"); +const fs = require("node:fs"); +const path = require("node:path"); const logger = require("./logger"); -const RAW_YML_URL = +const REGISTRY_URL = "https://raw.githubusercontent.com/RusherDevelopment/rusherhack-plugins/main/data/plugins-and-themes.yml"; -const BACKUP_PATH = path.join(__dirname, "..", "cache", "plugins-and-themes.yml"); - -let plugins = []; -let themes = []; - -/** - * Ensure mc_versions is always a string so downstream code - * (search, version filtering) can safely call .split(","). - */ -function normalizeMcVersions(list, kind) { - for (const item of list) { - if (typeof item.mc_versions !== "string") { - logger.warn( - `Invalid mc_versions for ${kind} ${item.name}: ${JSON.stringify( - item.mc_versions, - )} (coercing to string)`, - ); - item.mc_versions = String(item.mc_versions || ""); + +const CACHE_PATH = path.join( + __dirname, "..", "cache", "plugins-and-themes.yml" +); + +function createDataStore({ + fetchRegistry = fetch, + cachePath = CACHE_PATH, + registryUrl = REGISTRY_URL, + log = logger, +} = {}) { + let plugins = []; + let themes = []; + let lastLiveRefresh = null; + let pendingRefresh = null; + + function parseRegistry(raw) { + const parsed = yaml.load(raw); + + if ( + !parsed || + !Array.isArray(parsed.plugins) || + !Array.isArray(parsed.themes) + ) { + throw new Error("Invalid registry structure"); } - } -} -/** - * Fetches the YAML and saves it locally. - */ -async function loadPluginData() { - try { - const res = await fetch(RAW_YML_URL); - if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); + if (!parsed.plugins.length && !parsed.themes.length) { + throw new Error("Registry contains no entries"); + } - const text = await res.text(); - const parsed = yaml.load(text); + function normalize(items, kind) { + return items.map((item, index) => { + if ( + !item || + typeof item.name !== "string" || + !item.name.trim() || + typeof item.repo !== "string" || + !item.repo.trim() + ) { + throw new Error( + `Invalid ${kind} entry at index ${index}` + ); + } + + return { + ...item, + mc_versions: String(item.mc_versions ?? ""), + }; + }); + } - plugins = parsed.plugins || []; - themes = parsed.themes || []; + return { + plugins: normalize(parsed.plugins, "plugin"), + themes: normalize(parsed.themes, "theme"), + }; + } - normalizeMcVersions(plugins, "plugin"); - normalizeMcVersions(themes, "theme"); + function saveCache(raw) { + fs.mkdirSync(path.dirname(cachePath), { + recursive: true, + }); - fs.mkdirSync(path.dirname(BACKUP_PATH), { recursive: true }); - fs.writeFileSync(BACKUP_PATH, text, "utf8"); + const temporary = `${cachePath}.${process.pid}.${Date.now()}.tmp`; - logger.success(`Loaded ${plugins.length} plugins and ${themes.length} themes`); - } catch (err) { - logger.error("Failed to fetch or parse plugin YAML from GitHub:"); - logger.error(err.stack || String(err)); + try { + fs.writeFileSync(temporary, raw, { flag: "wx" }); + fs.renameSync(temporary, cachePath); + } finally { + if (fs.existsSync(temporary)) { + fs.unlinkSync(temporary); + } + } + } - // Fallback to disk + async function performRefresh() { try { - const cached = fs.readFileSync(BACKUP_PATH, "utf8"); - const parsed = yaml.load(cached); + const controller = new AbortController(); + const timer = setTimeout( + () => controller.abort(), + 8000 + ); + + let raw; - plugins = parsed.plugins || []; - themes = parsed.themes || []; + try { + const response = await fetchRegistry(registryUrl, { + signal: controller.signal, + size: 2 * 1024 * 1024, + }); - normalizeMcVersions(plugins, "plugin"); - normalizeMcVersions(themes, "theme"); + if (!response.ok) { + throw new Error(`GitHub HTTP ${response.status}`); + } - logger.warn( - `Used cached YAML data from disk (${plugins.length} plugins, ${themes.length} themes)`, + raw = await response.text(); + } finally { + clearTimeout(timer); + } + + const next = parseRegistry(raw); + let cacheUpdated = true; + + try { + saveCache(raw); + } catch (error) { + cacheUpdated = false; + log.warn(`Registry cache write failed: ${error.message}`); + } + + plugins = next.plugins; + themes = next.themes; + lastLiveRefresh = new Date().toISOString(); + + log.success( + `Registry loaded: ${plugins.length} plugins, ` + + `${themes.length} themes` ); - } catch (fallbackError) { - logger.error("Failed to load cached YAML from disk as fallback"); - logger.error(fallbackError.stack || String(fallbackError)); - // Leave plugins/themes as empty arrays if both live + fallback fail - plugins = []; - themes = []; + return { + source: "github", + plugins: plugins.length, + themes: themes.length, + cacheUpdated, + lastLiveRefresh, + }; + } catch (error) { + log.warn(`GitHub registry refresh failed: ${error.message}`); + + // Never replace valid in-memory data with stale or empty data. + if (plugins.length || themes.length) { + log.warn("Preserving last-known-good in-memory registry"); + + return { + source: "memory", + plugins: plugins.length, + themes: themes.length, + lastLiveRefresh, + }; + } + + // Cold startup: attempt the validated disk cache. + try { + const raw = fs.readFileSync(cachePath, "utf8"); + const next = parseRegistry(raw); + + plugins = next.plugins; + themes = next.themes; + + log.warn( + `Using cached registry: ${plugins.length} plugins, ` + + `${themes.length} themes` + ); + + return { + source: "cache", + plugins: plugins.length, + themes: themes.length, + lastLiveRefresh, + }; + } catch (cacheError) { + throw new Error( + `Registry unavailable: GitHub (${error.message}); ` + + `cache (${cacheError.message})` + ); + } } } -} -function getPlugins() { - return plugins; -} - -function getThemes() { - return themes; -} + function loadPluginData() { + if (!pendingRefresh) { + pendingRefresh = performRefresh().finally(() => { + pendingRefresh = null; + }); + } -function getPluginByName(name) { - return plugins.find( - (p) => p.name && p.name.toLowerCase() === name.toLowerCase(), - ); -} + return pendingRefresh; + } -function getThemeByName(name) { - return themes.find( - (t) => t.name && t.name.toLowerCase() === name.toLowerCase(), - ); + return { + loadPluginData, + getPlugins: () => plugins, + getThemes: () => themes, + getPluginByName: (name) => + plugins.find( + (p) => p.name.toLowerCase() === String(name).toLowerCase() + ), + getThemeByName: (name) => + themes.find( + (t) => t.name.toLowerCase() === String(name).toLowerCase() + ), + }; } module.exports = { - loadPluginData, - getPlugins, - getThemes, - getPluginByName, - getThemeByName, + ...createDataStore(), + createDataStore, }; diff --git a/test/dataStore.test.js b/test/dataStore.test.js new file mode 100644 index 0000000..5e0b5f1 --- /dev/null +++ b/test/dataStore.test.js @@ -0,0 +1,188 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { createDataStore } = require("../src/utils/dataStore"); + +const VALID = `plugins: + - name: Test Plugin + repo: Example/TestPlugin + mc_versions: 1.21 +themes: + - name: Test Theme + repo: Example/TestTheme + mc_versions: '1.21.11' +`; + +const INVALID = "plugins: [broken"; +const EMPTY = "plugins: []\nthemes: []\n"; + +const log = { + warn() {}, + success() {}, +}; + +const response = (body) => ({ + ok: true, + text: async () => body, +}); + +const offline = async () => { + throw new Error("Simulated network outage"); +}; + +function fixture(t) { + const dir = fs.mkdtempSync( + path.join(os.tmpdir(), "rusher-registry-") + ); + + t.after(() => fs.rmSync(dir, { + recursive: true, + force: true, + })); + + return path.join(dir, "registry.yml"); +} + +test("loads valid GitHub registry and saves cache", async (t) => { + const cachePath = fixture(t); + + const store = createDataStore({ + fetchRegistry: async () => response(VALID), + cachePath, + log, + }); + + const result = await store.loadPluginData(); + + assert.equal(result.source, "github"); + assert.equal(result.plugins, 1); + assert.equal(result.themes, 1); + assert.equal(result.cacheUpdated, true); + assert.equal( + store.getPluginByName("Test Plugin").mc_versions, + "1.21" + ); + assert.equal(fs.readFileSync(cachePath, "utf8"), VALID); +}); + +test("preserves memory when GitHub goes offline", async (t) => { + const cachePath = fixture(t); + let online = true; + + const store = createDataStore({ + fetchRegistry: async () => { + if (!online) return offline(); + return response(VALID); + }, + cachePath, + log, + }); + + await store.loadPluginData(); + online = false; + + const result = await store.loadPluginData(); + + assert.equal(result.source, "memory"); + assert.equal(store.getPlugins().length, 1); + assert.equal(store.getThemes().length, 1); +}); + +test("rejects malformed downloads without corrupting cache", async (t) => { + const cachePath = fixture(t); + let body = VALID; + + const store = createDataStore({ + fetchRegistry: async () => response(body), + cachePath, + log, + }); + + await store.loadPluginData(); + body = INVALID; + + const result = await store.loadPluginData(); + + assert.equal(result.source, "memory"); + assert.equal(fs.readFileSync(cachePath, "utf8"), VALID); +}); + +test("rejects unexpectedly empty registry", async (t) => { + const cachePath = fixture(t); + let body = VALID; + + const store = createDataStore({ + fetchRegistry: async () => response(body), + cachePath, + log, + }); + + await store.loadPluginData(); + body = EMPTY; + + const result = await store.loadPluginData(); + + assert.equal(result.source, "memory"); + assert.equal(store.getPlugins().length, 1); + assert.equal(fs.readFileSync(cachePath, "utf8"), VALID); +}); + +test("recovers from cache during offline startup", async (t) => { + const cachePath = fixture(t); + fs.writeFileSync(cachePath, VALID); + + const store = createDataStore({ + fetchRegistry: offline, + cachePath, + log, + }); + + const result = await store.loadPluginData(); + + assert.equal(result.source, "cache"); + assert.equal(result.plugins, 1); + assert.equal(result.themes, 1); +}); + +test("fails startup if both GitHub and cache are unusable", async (t) => { + const cachePath = fixture(t); + fs.writeFileSync(cachePath, INVALID); + + const store = createDataStore({ + fetchRegistry: offline, + cachePath, + log, + }); + + await assert.rejects( + store.loadPluginData(), + /Registry unavailable/ + ); +}); + +test("combines simultaneous refresh requests", async (t) => { + const cachePath = fixture(t); + let requests = 0; + + const store = createDataStore({ + fetchRegistry: async () => { + requests++; + await new Promise((resolve) => setImmediate(resolve)); + return response(VALID); + }, + cachePath, + log, + }); + + const results = await Promise.all([ + store.loadPluginData(), + store.loadPluginData(), + store.loadPluginData(), + ]); + + assert.equal(requests, 1); + assert.ok(results.every((r) => r.source === "github")); +}); From ff4d1a4fffa4d6827f8e83e01a152804a2358899 Mon Sep 17 00:00:00 2001 From: GarlicRot Date: Fri, 23 Oct 2026 15:40:42 +0000 Subject: [PATCH 04/11] ci: add automated tests and security checks --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f0d902d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: + - main + - feature/v2-upgrade + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + name: Validate Bot + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '24' + package-manager-cache: false + + - name: Install dependencies + run: npm ci + + - name: Check JavaScript syntax + run: | + find src scripts test -type f -name '*.js' -print0 | + xargs -0 -r -n 1 node --check + + - name: Run automated tests + run: node --test test/*.test.js + + - name: Security audit + run: npm audit --omit=dev From 74c9e5b45f4640d5728b53562eb6eb6e31a97ef3 Mon Sep 17 00:00:00 2001 From: GarlicRot Date: Fri, 23 Oct 2026 15:48:43 +0000 Subject: [PATCH 05/11] fix(discord): replace deprecated ephemeral options --- src/commands/dev/refresh.js | 6 +++--- src/commands/search/search.js | 3 ++- src/events/interactionCreate.js | 7 +++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/commands/dev/refresh.js b/src/commands/dev/refresh.js index 7010a74..f743063 100644 --- a/src/commands/dev/refresh.js +++ b/src/commands/dev/refresh.js @@ -1,4 +1,4 @@ -const { SlashCommandBuilder } = require("discord.js"); +const { SlashCommandBuilder, MessageFlags } = require("discord.js"); const { loadPluginData } = require("../../utils/dataStore"); const DEV_USER_ID = "119982148945051651"; @@ -12,12 +12,12 @@ module.exports = { if (interaction.user.id !== DEV_USER_ID) { return interaction.reply({ content: "❌ You are not authorized to use this command.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); } try { - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); } catch (e) { console.error("Failed to defer reply:", e); return; diff --git a/src/commands/search/search.js b/src/commands/search/search.js index 749a61e..ce5758c 100644 --- a/src/commands/search/search.js +++ b/src/commands/search/search.js @@ -1,5 +1,6 @@ const { SlashCommandBuilder, + MessageFlags, EmbedBuilder, ActionRowBuilder, ButtonBuilder, @@ -661,7 +662,7 @@ module.exports = { try { await interaction.reply({ ...fallback, - ephemeral: true, + flags: MessageFlags.Ephemeral, }); } catch { // ignore diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index c3631b6..c844700 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -1,4 +1,5 @@ const logger = require("../utils/logger"); +const { MessageFlags } = require("discord.js"); module.exports = { name: "interactionCreate", @@ -20,7 +21,6 @@ module.exports = { const payload = { content: "❌ An error occurred while processing this command.", - ephemeral: true, }; // Use editReply if we've already replied/deferred, otherwise reply @@ -32,7 +32,10 @@ module.exports = { typeof interaction.isRepliable === "function" && interaction.isRepliable() ) { - await interaction.reply(payload).catch(() => {}); + await interaction.reply({ + ...payload, + flags: MessageFlags.Ephemeral, + }).catch(() => {}); } } } From 50d5173931bf22cbcbb8028f960d29bbe29cfef0 Mon Sep 17 00:00:00 2001 From: GarlicRot Date: Fri, 23 Oct 2026 16:32:23 +0000 Subject: [PATCH 06/11] feat(search): improve version discovery and creator filtering --- src/commands/search/search.js | 112 ++++++++++++++++++++++---------- test/searchAutocomplete.test.js | 105 ++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 36 deletions(-) create mode 100644 test/searchAutocomplete.test.js diff --git a/src/commands/search/search.js b/src/commands/search/search.js index ce5758c..9e4b983 100644 --- a/src/commands/search/search.js +++ b/src/commands/search/search.js @@ -142,7 +142,7 @@ function getItemsByCreator(creator, mc) { const themeItems = filterAndSort(getThemes, creator, mc) .filter(byCreator) - .map((it) => getThemeByName(it.name) || it); + .map((it) => ({ ...it, type: "theme" })); return [...pluginItems, ...themeItems]; } @@ -155,7 +155,7 @@ function getItemsByVersion(mc) { (it) => getPluginByName(it.name) || it, ); const themeItems = filterAndSort(getThemes, "", mc).map( - (it) => getThemeByName(it.name) || it, + (it) => ({ ...it, type: "theme" }), ); return [...pluginItems, ...themeItems]; @@ -254,17 +254,23 @@ function buildVersionPagePayload(mc, items, page) { title, desc, latestLine, - it.mc_versions ? `MC: \`${it.mc_versions}\`` : null, ] .filter(Boolean) .join("\n"); }); + const themeCount = items.filter( + (item) => item.type === "theme" + ).length; + const pluginCount = total - themeCount; + const embed = new EmbedBuilder() - .setTitle(`Items supporting MC ${mc}`) + .setTitle(`Registry listings for MC ${mc}`) .setDescription(blocks) .setFooter({ - text: `Page ${p}/${pageCount} • ${total} total`, + text: `Page ${p}/${pageCount} • ` + + `${pluginCount} ${pluginCount === 1 ? "plugin" : "plugins"} • ` + + `${themeCount} ${themeCount === 1 ? "theme" : "themes"}`, }); const components = []; @@ -312,11 +318,6 @@ module.exports = { .setDescription("Exact plugin name") .setRequired(true) .setAutocomplete(true), - ) - .addStringOption((o) => - o - .setName("mc_version") - .setDescription("Filter by MC version (e.g., 1.21.4)"), ), ) @@ -331,11 +332,6 @@ module.exports = { .setDescription("Exact theme name") .setRequired(true) .setAutocomplete(true), - ) - .addStringOption((o) => - o - .setName("mc_version") - .setDescription("Filter by MC version (e.g., 1.21.4)"), ), ) @@ -354,7 +350,8 @@ module.exports = { .addStringOption((o) => o .setName("mc_version") - .setDescription("Filter by MC version (e.g., 1.21.4)"), + .setDescription("Choose a Minecraft version") + .setAutocomplete(true), ), ) @@ -368,8 +365,9 @@ module.exports = { .addStringOption((o) => o .setName("mc_version") - .setDescription("Minecraft version, e.g., 1.21.4") - .setRequired(true), + .setDescription("Minecraft version") + .setRequired(true) + .setAutocomplete(true), ), ), @@ -381,6 +379,53 @@ module.exports = { interaction.options.getFocused(true)?.value || "" ).toLowerCase(); + if (interaction.options.getFocused(true)?.name === "mc_version") { + let items = [...getPlugins(), ...getThemes()]; + + if (sub === "creator") { + // Read the creator already supplied in this interaction. + const creator = String( + interaction.options.get("name")?.value || + interaction.options.getString("name") || + "" + ).trim().toLowerCase(); + + if (!creator) { + return interaction.respond([]); + } + + items = items.filter( + (item) => + String(item.creator?.name || "") + .trim() + .toLowerCase() === creator + ); + } + + const versions = [...new Set( + items + .flatMap((item) => + String(item.mc_versions || "").split(",") + ) + .map((version) => version.trim()) + .filter((version) => + /^[0-9]+[.][0-9]+([.][0-9]+)?$/.test(version) + ) + )].sort((a, b) => + b.localeCompare(a, undefined, { numeric: true }) + ); + + return interaction.respond( + versions + .filter((version) => version.includes(focused)) + .slice(0, 25) + .map((version) => ({ + name: version, + value: version, + })) + ); + } + if (sub === "plugin") { const names = getPlugins() .map((p) => p.name) @@ -425,8 +470,19 @@ module.exports = { list.map((n) => ({ name: n, value: n })), ); } - } catch { - // swallow autocomplete errors so they don't nuke the bot + } catch (error) { + console.error("[Search autocomplete] Failed:", error); + + if (!interaction.responded) { + try { + await interaction.respond([]); + } catch (responseError) { + console.error( + "[Search autocomplete] Response failed:", + responseError + ); + } + } } }, @@ -437,7 +493,6 @@ module.exports = { /* ----- /search plugin ----- */ if (sub === "plugin") { const name = interaction.options.getString("name", true); - const mc = interaction.options.getString("mc_version") || ""; const item = getPluginByName(name); if (!item) { @@ -447,13 +502,6 @@ module.exports = { }); } - if (mc && !isVersionSupported(item, mc)) { - return interaction.reply({ - content: `Plugin **${name}** does not support MC \`${mc}\`.`, - allowedMentions: { parse: [] }, - }); - } - const stats = await getRepoDetails(item.repo); const embed = await createPluginEmbed( @@ -499,7 +547,6 @@ module.exports = { /* ----- /search theme ----- */ if (sub === "theme") { const name = interaction.options.getString("name", true); - const mc = interaction.options.getString("mc_version") || ""; const item = getThemeByName(name); if (!item) { @@ -509,13 +556,6 @@ module.exports = { }); } - if (mc && !isVersionSupported(item, mc)) { - return interaction.reply({ - content: `Theme **${name}** does not support MC \`${mc}\`.`, - allowedMentions: { parse: [] }, - }); - } - const stats = await getRepoDetails(item.repo); const embed = await createPluginEmbed( diff --git a/test/searchAutocomplete.test.js b/test/searchAutocomplete.test.js new file mode 100644 index 0000000..a415372 --- /dev/null +++ b/test/searchAutocomplete.test.js @@ -0,0 +1,105 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const yaml = require("js-yaml"); + +const registry = yaml.load( + fs.readFileSync( + path.join(__dirname, "../src/cache/plugins-and-themes.yml"), + "utf8" + ) +); + +const plugins = registry.plugins || []; +const themes = registry.themes || []; +const items = [...plugins, ...themes]; + +const store = require("../src/utils/dataStore"); +store.getPlugins = () => plugins; +store.getThemes = () => themes; + +const search = require("../src/commands/search/search"); + +async function getChoices(subcommand, creator = "", focused = "") { + let choices; + + await search.autocomplete({ + options: { + getSubcommand: () => subcommand, + getFocused: () => ({ + name: "mc_version", + value: focused + }), + get: name => + name === "name" ? { value: creator } : null, + getString: name => + name === "name" ? creator : null + }, + respond: async results => { + choices = results; + } + }); + + assert.ok(Array.isArray(choices)); + return choices.map(choice => choice.value); +} + +function expectedVersions(entries) { + return [...new Set( + entries.flatMap(item => + String(item.mc_versions || "") + .split(",") + .map(version => version.trim()) + .filter(version => + /^\d+\.\d+(?:\.\d+)?$/.test(version) + ) + ) + )].sort((a, b) => + b.localeCompare(a, undefined, { numeric: true }) + ); +} + +test("creator versions match YAML registry", async () => { + const creator = "GarlicRot"; + + const matching = items.filter(item => + item.creator?.name?.toLowerCase() === creator.toLowerCase() + ); + + assert.ok(matching.length > 0); + + const actual = await getChoices("creator", creator); + assert.deepEqual(actual, expectedVersions(matching).slice(0, 25)); +}); + +test("different creators receive their own versions", async () => { + const creator = "h1tm4nqq"; + + const matching = items.filter(item => + item.creator?.name?.toLowerCase() === creator.toLowerCase() + ); + + assert.ok(matching.length > 0); + + const actual = await getChoices("creator", creator); + assert.deepEqual(actual, expectedVersions(matching).slice(0, 25)); +}); + +test("unknown creator receives no versions", async () => { + const actual = await getChoices( + "creator", + "CreatorThatDoesNotExist123" + ); + + assert.deepEqual(actual, []); +}); + +test("global version search includes all registry versions", async () => { + const actual = await getChoices("version"); + + assert.deepEqual( + actual, + expectedVersions(items).slice(0, 25) + ); +}); From 891e69e45ff0ec51141315f48ad6de0b019e410c Mon Sep 17 00:00:00 2001 From: GarlicRot Date: Fri, 23 Oct 2026 16:52:04 +0000 Subject: [PATCH 07/11] feat(search): add GitHub stats to creator listings --- src/commands/search/search.js | 42 +++++++++++++++++++++++++++-------- src/utils/getRepoDetails.js | 14 +++++++----- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/commands/search/search.js b/src/commands/search/search.js index 9e4b983..dfc91ad 100644 --- a/src/commands/search/search.js +++ b/src/commands/search/search.js @@ -161,14 +161,36 @@ function getItemsByVersion(mc) { return [...pluginItems, ...themeItems]; } -function buildCreatorPagePayload(creator, mc, items, page) { +async function buildCreatorPagePayload(creator, mc, items, page) { const { slice, p, pageCount, total } = paginate(items, page, PER_PAGE); - const blocks = toBlocks(slice, (it) => { + const githubStats = await Promise.all( + slice.map(async (item) => { + try { + return await getRepoDetails(item.repo, { summaryOnly: true }); + } catch (error) { + console.warn(`[Creator stats] ${item.repo}: ${error.message}`); + return {}; + } + }) + ); + + const blocks = slice.map((it, index) => { const kind = labelForItem(it); + const stats = githubStats[index] || {}; + + const counts = [ + Number.isFinite(stats.stars) + ? `★${stats.stars.toLocaleString("en-US")}` + : null, + Number.isFinite(stats.downloadCount) + ? `⬇${stats.downloadCount.toLocaleString("en-US")}` + : null, + ].filter(Boolean); + const title = `**[${it.name}](${ it.repo ? `https://github.com/${it.repo}` : "#" - })** — ${kind}`; + })** — ${kind}${counts.length ? ` — ${counts.join(" — ")}` : ""}`; const desc = truncate(it.description, 180); const url = getLatestUrl(it); @@ -186,7 +208,7 @@ function buildCreatorPagePayload(creator, mc, items, page) { ] .filter(Boolean) .join("\n"); - }); + }).join("\n\n"); const embed = new EmbedBuilder() .setTitle(`Items by Creator: ${creator}`) @@ -614,8 +636,9 @@ module.exports = { }); } - const payload = buildCreatorPagePayload(creator, mc, items, 1); - return interaction.reply(payload); + await interaction.deferReply(); + const payload = await buildCreatorPagePayload(creator, mc, items, 1); + return interaction.editReply(payload); } /* ----- /search version ----- */ @@ -662,8 +685,9 @@ module.exports = { }); } - const payload = buildCreatorPagePayload(creator, mc, items, page); - return interaction.update(payload); + await interaction.deferUpdate(); + const payload = await buildCreatorPagePayload(creator, mc, items, page); + return interaction.editReply(payload); } if (mode === "version") { @@ -694,7 +718,7 @@ module.exports = { if (interaction.deferred || interaction.replied) { try { - await interaction.update(fallback); + await interaction.editReply(fallback); } catch { // ignore } diff --git a/src/utils/getRepoDetails.js b/src/utils/getRepoDetails.js index 318482b..c64a068 100644 --- a/src/utils/getRepoDetails.js +++ b/src/utils/getRepoDetails.js @@ -2,9 +2,10 @@ const axios = require("axios"); const GITHUB_API = "https://api.github.com"; const CACHE_TTL = 10 * 60 * 1000; +const SUMMARY_CACHE_TTL = 60 * 60 * 1000; const cache = new Map(); -async function getRepoDetails(repoSlug) { +async function getRepoDetails(repoSlug, { summaryOnly = false } = {}) { if ( typeof repoSlug !== "string" || !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repoSlug) @@ -12,7 +13,7 @@ async function getRepoDetails(repoSlug) { return {}; } - const key = repoSlug.toLowerCase(); + const key = `${repoSlug.toLowerCase()}:${summaryOnly ? "summary" : "full"}`; const cached = cache.get(key); if (cached && cached.expires > Date.now()) { @@ -40,9 +41,12 @@ async function getRepoDetails(repoSlug) { const requests = [ axios.get(base, config), axios.get(`${base}/releases?per_page=100`, config), - axios.get(`${base}/commits?per_page=1`, config), ]; + if (!summaryOnly) { + requests.push(axios.get(`${base}/commits?per_page=1`, config)); + } + const results = await Promise.allSettled(requests); const stats = {}; @@ -75,7 +79,7 @@ async function getRepoDetails(repoSlug) { } } - if (commits.status === "fulfilled") { + if (commits?.status === "fulfilled") { const date = commits.value.data[0]?.commit?.committer?.date; @@ -99,7 +103,7 @@ async function getRepoDetails(repoSlug) { cache.set(key, { data: stats, expires: Date.now() + - (Object.keys(stats).length ? CACHE_TTL : 30000), + (Object.keys(stats).length ? (summaryOnly ? SUMMARY_CACHE_TTL : CACHE_TTL) : 30000), }); return stats; From 760abdbfbd5039c73113a580bd7cef7a748d8b85 Mon Sep 17 00:00:00 2001 From: GarlicRot Date: Fri, 23 Oct 2026 17:25:23 +0000 Subject: [PATCH 08/11] feat(search): polish embeds and unify GitHub statistics --- src/commands/search/search.js | 48 +++++++++++++++++++----- src/utils/embedBuilder.js | 69 ++++++++++++++++++++--------------- 2 files changed, 77 insertions(+), 40 deletions(-) diff --git a/src/commands/search/search.js b/src/commands/search/search.js index dfc91ad..6def42e 100644 --- a/src/commands/search/search.js +++ b/src/commands/search/search.js @@ -211,6 +211,7 @@ async function buildCreatorPagePayload(creator, mc, items, page) { }).join("\n\n"); const embed = new EmbedBuilder() + .setColor(0x5865f2) .setTitle(`Items by Creator: ${creator}`) .setDescription(blocks) .setFooter({ @@ -255,14 +256,36 @@ async function buildCreatorPagePayload(creator, mc, items, page) { }; } -function buildVersionPagePayload(mc, items, page) { +async function buildVersionPagePayload(mc, items, page) { const { slice, p, pageCount, total } = paginate(items, page, PER_PAGE); - const blocks = toBlocks(slice, (it) => { + const githubStats = await Promise.all( + slice.map(async (item) => { + try { + return await getRepoDetails(item.repo, { summaryOnly: true }); + } catch (error) { + console.warn(`[Version stats] ${item.repo}: ${error.message}`); + return {}; + } + }) + ); + + const blocks = slice.map((it, index) => { const kind = labelForItem(it); + const stats = githubStats[index] || {}; + + const counts = [ + Number.isFinite(stats.stars) + ? `★${stats.stars.toLocaleString("en-US")}` + : null, + Number.isFinite(stats.downloadCount) + ? `⬇${stats.downloadCount.toLocaleString("en-US")}` + : null, + ].filter(Boolean); + const title = `**[${it.name}](${ it.repo ? `https://github.com/${it.repo}` : "#" - })** — ${kind}`; + })** — ${kind}${counts.length ? ` — ${counts.join(" — ")}` : ""}`; const desc = truncate(it.description, 180); const url = getLatestUrl(it); @@ -279,7 +302,7 @@ function buildVersionPagePayload(mc, items, page) { ] .filter(Boolean) .join("\n"); - }); + }).join("\n\n"); const themeCount = items.filter( (item) => item.type === "theme" @@ -287,6 +310,7 @@ function buildVersionPagePayload(mc, items, page) { const pluginCount = total - themeCount; const embed = new EmbedBuilder() + .setColor(0xf1c40f) .setTitle(`Registry listings for MC ${mc}`) .setDescription(blocks) .setFooter({ @@ -524,6 +548,7 @@ module.exports = { }); } + await interaction.deferReply(); const stats = await getRepoDetails(item.repo); const embed = await createPluginEmbed( @@ -563,7 +588,7 @@ module.exports = { }); } - return interaction.reply({ embeds: [embed] }); + return interaction.editReply({ embeds: [embed] }); } /* ----- /search theme ----- */ @@ -578,6 +603,7 @@ module.exports = { }); } + await interaction.deferReply(); const stats = await getRepoDetails(item.repo); const embed = await createPluginEmbed( @@ -617,7 +643,7 @@ module.exports = { }); } - return interaction.reply({ embeds: [embed] }); + return interaction.editReply({ embeds: [embed] }); } /* ----- /search creator ----- */ @@ -654,8 +680,9 @@ module.exports = { }); } - const payload = buildVersionPagePayload(mc, items, 1); - return interaction.reply(payload); + await interaction.deferReply(); + const payload = await buildVersionPagePayload(mc, items, 1); + return interaction.editReply(payload); } }, @@ -703,8 +730,9 @@ module.exports = { }); } - const payload = buildVersionPagePayload(mc, items, page); - return interaction.update(payload); + await interaction.deferUpdate(); + const payload = await buildVersionPagePayload(mc, items, page); + return interaction.editReply(payload); } } catch (error) { console.error( diff --git a/src/utils/embedBuilder.js b/src/utils/embedBuilder.js index e80fa47..8f3df46 100644 --- a/src/utils/embedBuilder.js +++ b/src/utils/embedBuilder.js @@ -35,7 +35,7 @@ function createPluginEmbed( const repoUrl = `https://github.com/${data.repo}`; const embed = new EmbedBuilder() - .setColor(data.is_core ? 0x3498db : 0x00ff88) + .setColor(isPlugin ? (data.is_core ? 0x3498db : 0x00ff88) : 0x9b59b6) .setTitle(data.name || "Unnamed Plugin") .setURL(repoUrl) .setDescription( @@ -46,8 +46,7 @@ function createPluginEmbed( .setFooter({ text: "Rusher Plugin Bot", iconURL: getBotAvatarURL(client), - }) - .setTimestamp(Date.now()); + }); if (data.creator?.name || data.creator?.avatar) { embed.setAuthor({ @@ -56,55 +55,65 @@ function createPluginEmbed( }); } - let combinedInfo = ""; + const fields = []; + const infoLines = []; + + if (isPlugin && data.is_core === true) { + infoLines.push("**Core Plugin**"); + } if (data.mc_versions) { - combinedInfo += `**MC Versions:** ${data.mc_versions}\n`; + infoLines.push( + `**Listed MC Versions:** ${data.mc_versions}` + ); } - if (data.is_core !== undefined) { - combinedInfo += `**Core Plugin:** ${data.is_core ? "✅" : "❌"}\n`; + if (infoLines.length) { + fields.push({ + name: isPlugin ? "Plugin Details" : "Theme Details", + value: infoLines.join("\n"), + inline: false, + }); } const { stars, lastCommit, downloadCount } = githubInfo || {}; - let githubStats = ""; + const statsLines = []; - if (stars !== undefined) { - githubStats += `**Stars:** ${stars}\n`; + if (Number.isFinite(stars)) { + statsLines.push( + `★ **Stars:** ${stars.toLocaleString("en-US")}` + ); } - if (downloadCount !== undefined) { - githubStats += `**Downloads:** ${downloadCount}\n`; + if (Number.isFinite(downloadCount)) { + statsLines.push( + `⬇ **Downloads:** ${downloadCount.toLocaleString("en-US")}` + ); } if (lastCommit) { - const commitDate = new Date(lastCommit).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); - githubStats += `**Last Commit:** ${commitDate}`; - } + const date = new Date(lastCommit); - const fields = []; + if (!Number.isNaN(date.getTime())) { + const formatted = date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); - if (combinedInfo.trim().length > 0) { - fields.push({ - name: isPlugin ? "- Plugin Info:" : "- Theme Info:", - value: combinedInfo.trim(), - inline: true, - }); + statsLines.push(`**Last Commit:** ${formatted}`); + } } - if (githubStats.trim().length > 0) { + if (statsLines.length) { fields.push({ - name: "- GitHub Stats:", - value: githubStats.trim(), + name: "GitHub", + value: statsLines.join("\n"), inline: true, }); } - if (fields.length > 0) { + if (fields.length) { embed.addFields(fields); } From a5d9c32fa08076bf626b6ad4bbed5a8add5d3dc7 Mon Sep 17 00:00:00 2001 From: GarlicRot Date: Fri, 23 Oct 2026 17:29:02 +0000 Subject: [PATCH 09/11] fix(github): deduplicate concurrent API requests --- src/utils/getRepoDetails.js | 29 ++++++++++++++++++++- test/getRepoDetails.test.js | 51 +++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 test/getRepoDetails.test.js diff --git a/src/utils/getRepoDetails.js b/src/utils/getRepoDetails.js index c64a068..7ea7feb 100644 --- a/src/utils/getRepoDetails.js +++ b/src/utils/getRepoDetails.js @@ -4,8 +4,9 @@ const GITHUB_API = "https://api.github.com"; const CACHE_TTL = 10 * 60 * 1000; const SUMMARY_CACHE_TTL = 60 * 60 * 1000; const cache = new Map(); +const inFlight = new Map(); -async function getRepoDetails(repoSlug, { summaryOnly = false } = {}) { +async function fetchRepoDetails(repoSlug, { summaryOnly = false } = {}) { if ( typeof repoSlug !== "string" || !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repoSlug) @@ -109,4 +110,30 @@ async function getRepoDetails(repoSlug, { summaryOnly = false } = {}) { return stats; } +// Share ongoing requests for the same repository and mode. +async function getRepoDetails(repoSlug, { summaryOnly = false } = {}) { + if ( + typeof repoSlug !== "string" || + !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repoSlug) + ) { + return {}; + } + + const key = `${repoSlug.toLowerCase()}:${summaryOnly ? "summary" : "full"}`; + + const existing = inFlight.get(key); + if (existing) { + return existing; + } + + const request = fetchRepoDetails(repoSlug, { summaryOnly }); + inFlight.set(key, request); + + try { + return await request; + } finally { + inFlight.delete(key); + } +} + module.exports = { getRepoDetails }; diff --git a/test/getRepoDetails.test.js b/test/getRepoDetails.test.js new file mode 100644 index 0000000..7331386 --- /dev/null +++ b/test/getRepoDetails.test.js @@ -0,0 +1,51 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const axios = require("axios"); +const { getRepoDetails } = require("../src/utils/getRepoDetails"); + +test("deduplicates simultaneous GitHub summary requests", async () => { + const originalGet = axios.get; + const pending = []; + + try { + axios.get = (url) => + new Promise((resolve) => { + pending.push({ url, resolve }); + }); + + const requests = Array.from({ length: 6 }, () => + getRepoDetails("TestOwner/TestRepo", { + summaryOnly: true + }) + ); + + // One repository request and one releases request. + assert.equal(pending.length, 2); + + for (const request of pending) { + request.resolve({ + data: request.url.includes("/releases?") + ? [{ assets: [{ download_count: 520 }] }] + : { stargazers_count: 4 } + }); + } + + const results = await Promise.all(requests); + + for (const result of results) { + assert.equal(result.stars, 4); + assert.equal(result.downloadCount, 520); + } + + // Completed results should use the existing cache. + const cached = await getRepoDetails( + "TestOwner/TestRepo", + { summaryOnly: true } + ); + + assert.equal(cached.stars, 4); + assert.equal(pending.length, 2); + } finally { + axios.get = originalGet; + } +}); From 77a2e60cb91424fc03f132b762a177e35c8b9dda Mon Sep 17 00:00:00 2001 From: GarlicRot Date: Fri, 23 Oct 2026 17:33:35 +0000 Subject: [PATCH 10/11] fix(github): handle API rate limits and recovery --- src/utils/getRepoDetails.js | 63 ++++++++++++++++++++++ test/getRepoDetails.test.js | 105 ++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/src/utils/getRepoDetails.js b/src/utils/getRepoDetails.js index 7ea7feb..d268ef3 100644 --- a/src/utils/getRepoDetails.js +++ b/src/utils/getRepoDetails.js @@ -5,6 +5,61 @@ const CACHE_TTL = 10 * 60 * 1000; const SUMMARY_CACHE_TTL = 60 * 60 * 1000; const cache = new Map(); const inFlight = new Map(); +let rateLimitUntil = 0; + +// Distinguish GitHub rate limits from ordinary permission errors. +function recordRateLimit(error) { + const response = error?.response; + + if (![403, 429].includes(response?.status)) return; + + const headers = response.headers || {}; + const remaining = String(headers["x-ratelimit-remaining"] ?? ""); + const reset = Number(headers["x-ratelimit-reset"]); + const retryHeader = headers["retry-after"]; + const retryAfter = Number(retryHeader); + const message = String(response.data?.message || ""); + + const isLimited = + remaining === "0" || + response.status === 429 || + retryHeader !== undefined || + /secondary rate limit|abuse detection/i.test(message); + + if (!isLimited) return; + + const now = Date.now(); + const deadlines = []; + + if ( + remaining === "0" && + Number.isFinite(reset) && + reset > 0 + ) { + deadlines.push(reset * 1000 + 1000); + } + + if ( + retryHeader !== undefined && + Number.isFinite(retryAfter) && + retryAfter >= 0 + ) { + deadlines.push(now + Math.max(1000, retryAfter * 1000)); + } + + const until = deadlines.length + ? Math.max(now + 1000, ...deadlines) + : now + 60000; + + if (until > rateLimitUntil) { + rateLimitUntil = until; + + console.warn( + `[GitHub] Rate limited; pausing requests until ` + + new Date(until).toISOString() + ); + } +} async function fetchRepoDetails(repoSlug, { summaryOnly = false } = {}) { if ( @@ -21,6 +76,13 @@ async function fetchRepoDetails(repoSlug, { summaryOnly = false } = {}) { return cached.data; } + // Cached data remains available during a GitHub cooldown. + if (Date.now() < rateLimitUntil) { + return {}; + } + + rateLimitUntil = 0; + const headers = { "User-Agent": "RusherPluginBot", "Accept": "application/vnd.github+json", @@ -94,6 +156,7 @@ async function fetchRepoDetails(repoSlug, { summaryOnly = false } = {}) { results.forEach((result, index) => { if (result.status === "rejected") { const error = result.reason; + recordRateLimit(error); console.warn( `[GitHub] ${repoSlug} ${labels[index]} failed: ` + (error.response?.status || error.message) diff --git a/test/getRepoDetails.test.js b/test/getRepoDetails.test.js index 7331386..81f30ae 100644 --- a/test/getRepoDetails.test.js +++ b/test/getRepoDetails.test.js @@ -49,3 +49,108 @@ test("deduplicates simultaneous GitHub summary requests", async () => { axios.get = originalGet; } }); + +test("pauses new requests after a primary rate limit", async () => { + const originalGet = axios.get; + const originalNow = Date.now; + const calls = []; + let now = originalNow(); + + try { + Date.now = () => now; + + axios.get = async (url) => { + calls.push(url); + + if (url.includes("/RateOwner/")) { + const error = new Error("Rate limit exceeded"); + error.response = { + status: 403, + headers: { + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String( + Math.floor((now + 60000) / 1000) + ) + }, + data: { message: "API rate limit exceeded" } + }; + throw error; + } + + return { + data: url.includes("/releases?") + ? [] + : { stargazers_count: 8 } + }; + }; + + await getRepoDetails("RateOwner/RateRepo", { + summaryOnly: true + }); + + assert.equal(calls.length, 2); + + const blocked = await getRepoDetails( + "FreshOwner/FreshRepo", + { summaryOnly: true } + ); + + assert.deepEqual(blocked, {}); + assert.equal(calls.length, 2); + + // Simulate the GitHub rate limit resetting. + now += 65000; + + const recovered = await getRepoDetails( + "FreshOwner/FreshRepo", + { summaryOnly: true } + ); + + assert.equal(recovered.stars, 8); + assert.equal(calls.length, 4); + } finally { + Date.now = originalNow; + axios.get = originalGet; + } +}); + +test("ordinary 403 errors do not trigger a rate-limit pause", async () => { + const originalGet = axios.get; + let calls = 0; + + try { + axios.get = async (url) => { + calls++; + + if (url.includes("/PermissionOwner/")) { + const error = new Error("Forbidden"); + error.response = { + status: 403, + headers: {}, + data: { message: "Resource not accessible" } + }; + throw error; + } + + return { + data: url.includes("/releases?") + ? [] + : { stargazers_count: 12 } + }; + }; + + await getRepoDetails("PermissionOwner/Repo", { + summaryOnly: true + }); + + const result = await getRepoDetails( + "AllowedOwner/Repo", + { summaryOnly: true } + ); + + assert.equal(result.stars, 12); + assert.equal(calls, 4); + } finally { + axios.get = originalGet; + } +}); From 3ef3a3efb477f0ef5c1fd43410c3a550cfec921f Mon Sep 17 00:00:00 2001 From: GarlicRot Date: Fri, 23 Oct 2026 17:38:25 +0000 Subject: [PATCH 11/11] fix(github): preserve cached stats during API failures --- src/utils/getRepoDetails.js | 18 ++++- test/getRepoDetails.test.js | 151 ++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 4 deletions(-) diff --git a/src/utils/getRepoDetails.js b/src/utils/getRepoDetails.js index d268ef3..a594f02 100644 --- a/src/utils/getRepoDetails.js +++ b/src/utils/getRepoDetails.js @@ -78,7 +78,7 @@ async function fetchRepoDetails(repoSlug, { summaryOnly = false } = {}) { // Cached data remains available during a GitHub cooldown. if (Date.now() < rateLimitUntil) { - return {}; + return cached?.data || {}; } rateLimitUntil = 0; @@ -111,7 +111,8 @@ async function fetchRepoDetails(repoSlug, { summaryOnly = false } = {}) { } const results = await Promise.allSettled(requests); - const stats = {}; + const stats = { ...(cached?.data || {}) }; + let receivedFreshData = false; const [repo, releases, commits] = results; @@ -119,6 +120,7 @@ async function fetchRepoDetails(repoSlug, { summaryOnly = false } = {}) { const stars = repo.value.data.stargazers_count; if (Number.isFinite(stars)) { stats.stars = stars; + receivedFreshData = true; } } @@ -139,6 +141,7 @@ async function fetchRepoDetails(repoSlug, { summaryOnly = false } = {}) { ), 0 ); + receivedFreshData = true; } } @@ -148,6 +151,7 @@ async function fetchRepoDetails(repoSlug, { summaryOnly = false } = {}) { if (date) { stats.lastCommit = date; + receivedFreshData = true; } } @@ -164,10 +168,16 @@ async function fetchRepoDetails(repoSlug, { summaryOnly = false } = {}) { } }); + const ttl = receivedFreshData + ? (summaryOnly ? SUMMARY_CACHE_TTL : CACHE_TTL) + : 30_000; + cache.set(key, { data: stats, - expires: Date.now() + - (Object.keys(stats).length ? (summaryOnly ? SUMMARY_CACHE_TTL : CACHE_TTL) : 30000), + expires: Date.now() + ttl, + lastSuccessfulResponseAt: receivedFreshData + ? Date.now() + : (cached?.lastSuccessfulResponseAt ?? null), }); return stats; diff --git a/test/getRepoDetails.test.js b/test/getRepoDetails.test.js index 81f30ae..d54386f 100644 --- a/test/getRepoDetails.test.js +++ b/test/getRepoDetails.test.js @@ -154,3 +154,154 @@ test("ordinary 403 errors do not trigger a rate-limit pause", async () => { axios.get = originalGet; } }); + +test("preserves cached statistics during an API outage", async () => { + const originalGet = axios.get; + const originalNow = Date.now; + let now = originalNow(); + let offline = false; + let calls = 0; + + try { + Date.now = () => now; + + axios.get = async (url) => { + calls++; + + if (offline) throw new Error("GitHub unavailable"); + + return { + data: url.includes("/releases?") + ? [{ assets: [{ download_count: 520 }] }] + : { stargazers_count: 4 } + }; + }; + + const repo = "StaleOwner/RecoveryRepo"; + const options = { summaryOnly: true }; + + const first = await getRepoDetails(repo, options); + assert.equal(first.stars, 4); + assert.equal(first.downloadCount, 520); + + now += 3600001; + offline = true; + + const stale = await getRepoDetails(repo, options); + + assert.equal(stale.stars, 4); + assert.equal(stale.downloadCount, 520); + assert.equal(calls, 4); + + await getRepoDetails(repo, options); + assert.equal(calls, 4); + + now += 30001; + offline = false; + + const recovered = await getRepoDetails(repo, options); + + assert.equal(recovered.stars, 4); + assert.equal(recovered.downloadCount, 520); + assert.equal(calls, 6); + } finally { + Date.now = originalNow; + axios.get = originalGet; + } +}); + +test("preserves downloads when only release API fails", async () => { + const originalGet = axios.get; + const originalNow = Date.now; + let now = originalNow(); + let releaseFailure = false; + + try { + Date.now = () => now; + + axios.get = async (url) => { + if (url.includes("/releases?")) { + if (releaseFailure) throw new Error("Release API unavailable"); + + return { + data: [{ assets: [{ download_count: 520 }] }] + }; + } + + return { + data: { stargazers_count: releaseFailure ? 8 : 4 } + }; + }; + + const repo = "PartialOwner/Repo"; + const options = { summaryOnly: true }; + + await getRepoDetails(repo, options); + + now += 3600001; + releaseFailure = true; + + const updated = await getRepoDetails(repo, options); + + assert.equal(updated.stars, 8); + assert.equal(updated.downloadCount, 520); + } finally { + Date.now = originalNow; + axios.get = originalGet; + } +}); + +test("uses stale statistics during GitHub rate limits", async () => { + const originalGet = axios.get; + const originalNow = Date.now; + let now = originalNow(); + let calls = 0; + + try { + Date.now = () => now; + + axios.get = async (url) => { + calls++; + + if (url.includes("/CooldownOwner/")) { + const error = new Error("Rate limit exceeded"); + error.response = { + status: 403, + headers: { + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String( + Math.floor((now + 60000) / 1000) + ) + }, + data: { message: "API rate limit exceeded" } + }; + throw error; + } + + return { + data: url.includes("/releases?") + ? [{ assets: [{ download_count: 520 }] }] + : { stargazers_count: 4 } + }; + }; + + const repo = "CachedOwner/Repo"; + const options = { summaryOnly: true }; + + await getRepoDetails(repo, options); + + now += 3600001; + + await getRepoDetails("CooldownOwner/Repo", options); + + const before = calls; + const stale = await getRepoDetails(repo, options); + + assert.equal(stale.stars, 4); + assert.equal(stale.downloadCount, 520); + assert.equal(calls, before); + } finally { + Date.now = originalNow; + axios.get = originalGet; + } +});