diff --git a/.github/workflows/OmegaBot.yml b/.github/workflows/OmegaBot.yml index 181d528b..512cb562 100644 --- a/.github/workflows/OmegaBot.yml +++ b/.github/workflows/OmegaBot.yml @@ -33,6 +33,9 @@ jobs: - name: πŸ“¦ Install dependencies run: npm ci + - name: πŸ§ͺ Run Tests + run: npm run test + - name: 🧹 ESLint run: npm run lint diff --git a/README.md b/README.md index b0b8ca72..a9677c03 100644 --- a/README.md +++ b/README.md @@ -29,26 +29,46 @@ OmegaBot is a modular Discord bot designed to support development projects with ## Features -Current features +## Current features - Modular slash-command system (auto-loaded from dist/commands) -- /ping command for testing -- /summary command (local + LLM mode) -- /history command (DM + file fallback) -- /playback command (button pagination) -- /pagination command (inline paging) -- /timezone command (per-user IANA timezone) -- /changelog command (ephemeral preview) -- /fun commands: chucknorris, dadjoke, coinflip, dice, weather -- Centralized structured logging - -Planned features - -- FAQ storage and quick lookup +- Centralized interaction routing with safe error handling +- Structured logging (pino) + +### Core commands + +- /ping – health check +- /summary – conversation summaries (local + LLM mode) +- /history – conversation history (DM + file fallback) +- /playback – transcript playback with button pagination +- /pagination – reusable inline paging helper +- /timezone – per-user IANA timezone support +- /changelog – ephemeral release preview + +### FAQ system + +- /faq add – create persistent FAQ entries +- /faq get – retrieve FAQs by key +- /faq list – list FAQs with sorting and filtering +- /faq remove – remove FAQs with confirmation flow +- Persistent on-disk storage (versioned JSON) +- Usage tracking for FAQs +- Permission guardrails for destructive actions + +### Fun / utility commands + +- chucknorris +- dadjoke +- coinflip +- dice +- weather + +## Planned features + +- /help command for command discovery +- /docs command for documentation lookups - GitHub issues and pull request lookups - Pull request announcements -- Pagination for large history/playback (buttons or follow-ups) -- Per-user timezone support (store IANA timezone and apply to transcripts) - Improved summary output (highlights, action items, structured sections) --- @@ -195,6 +215,10 @@ OmegaBot is online β”‚ β”‚ β”‚ └── interactionHandler.ts β”‚ β”‚ β”œβ”€β”€ faq β”‚ β”‚ β”‚ β”œβ”€β”€ faqService.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ permissions.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ services.test.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ services.ts +β”‚ β”‚ β”‚ β”œβ”€β”€ store.test.ts β”‚ β”‚ β”‚ β”œβ”€β”€ store.ts β”‚ β”‚ β”‚ └── types.ts β”‚ β”‚ β”œβ”€β”€ github @@ -236,7 +260,8 @@ OmegaBot is online β”œβ”€β”€ package-lock.json β”œβ”€β”€ package.json β”œβ”€β”€ README.md -└── tsconfig.json +β”œβ”€β”€ tsconfig.json +└── vitest.config.ts ``` diff --git a/package-lock.json b/package-lock.json index 9adfb4a2..4f3f67c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "devDependencies": { "@eslint/js": "^9.12.0", "@types/node": "^24.10.2", + "@vitest/coverage-v8": "^4.0.16", "eslint": "^9.12.0", "eslint-config-prettier": "^9.1.0", "globals": "^15.11.0", @@ -23,7 +24,68 @@ "pino-pretty": "^13.1.3", "prettier": "^3.3.3", "typescript": "^5.9.3", - "typescript-eslint": "^8.0.0" + "typescript-eslint": "^8.0.0", + "vitest": "^4.0.16" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" } }, "node_modules/@discordjs/builders": { @@ -151,9 +213,451 @@ "license": "Apache-2.0", "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -365,12 +869,348 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@sapphire/async-queue": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", @@ -404,6 +1244,31 @@ "npm": ">=7.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -702,6 +1567,149 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.16.tgz", + "integrity": "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.16", + "ast-v8-to-istanbul": "^0.3.8", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.16", + "vitest": "4.0.16" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vladfrangu/async_event_emitter": { "version": "2.4.7", "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", @@ -799,6 +1807,28 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", + "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -855,6 +1885,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1067,6 +2107,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1091,7 +2138,49 @@ "hasown": "^2.0.2" }, "engines": { - "node": ">= 0.4" + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" } }, "node_modules/escape-string-regexp": { @@ -1264,6 +2353,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1283,6 +2382,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-copy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", @@ -1421,6 +2530,21 @@ "node": ">= 12.20" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1561,6 +2685,13 @@ "dev": true, "license": "MIT" }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -1653,6 +2784,60 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -1675,6 +2860,13 @@ "node": ">=10" } }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -1774,6 +2966,44 @@ "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", "license": "MIT" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1833,6 +3063,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -1880,6 +3129,17 @@ } } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -2027,6 +3287,20 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -2125,6 +3399,35 @@ "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", "license": "MIT" }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2213,6 +3516,48 @@ "node": ">=4" } }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -2275,6 +3620,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sonic-boom": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", @@ -2284,6 +3636,16 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -2293,6 +3655,20 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2328,6 +3704,23 @@ "real-require": "^0.2.0" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -2345,6 +3738,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -2452,6 +3855,159 @@ "punycode": "^2.1.0" } }, + "node_modules/vite": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/web-streams-polyfill": { "version": "4.0.0-beta.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", @@ -2493,6 +4049,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index e0fd8fef..1edc02e8 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "dev": "npm run build && node dist/bot.js", "start": "node dist/bot.js", "register": "npm run build && node dist/registerCommands.js", + "test": "vitest", + "test:run": "vitest run", "format": "prettier . --write", "format:check": "prettier . --check", "lint": "eslint . --ext .ts --max-warnings=0", @@ -24,6 +26,7 @@ "devDependencies": { "@eslint/js": "^9.12.0", "@types/node": "^24.10.2", + "@vitest/coverage-v8": "^4.0.16", "eslint": "^9.12.0", "eslint-config-prettier": "^9.1.0", "globals": "^15.11.0", @@ -31,6 +34,7 @@ "pino-pretty": "^13.1.3", "prettier": "^3.3.3", "typescript": "^5.9.3", - "typescript-eslint": "^8.0.0" + "typescript-eslint": "^8.0.0", + "vitest": "^4.0.16" } } diff --git a/src/commands/faq/faq.ts b/src/commands/faq/faq.ts index b86edd92..2a0a33f7 100644 --- a/src/commands/faq/faq.ts +++ b/src/commands/faq/faq.ts @@ -1,20 +1,17 @@ // src/commands/faq/faq.ts // -// Base /faq slash command (router). +// Base /faq slash command. // // Responsibilities: -// - Define the public Discord command shape (/faq + subcommands + options) -// - Own the interaction lifecycle (deferReply + editReply) -// - Route to subcommand handlers (thin controller) +// - Define the /faq command and subcommands (Discord API surface) +// - Own the interaction lifecycle (deferReply + final reply) +// - Route execution to subcommand handlers // // Non-goals: -// - No FAQ business logic +// - No FAQ business logic (validation, persistence, permissions, filtering logic) // - No file I/O -// - No persistence or validation rules beyond β€œrequired option” at the Discord level // -// All real work is delegated to: -// - src/commands/faq/subcommands/* (Discord-facing handlers) -// - src/services/faq/* (business logic + persistence) +// Subcommands live in ./subcommands/*.ts and delegate to src/services/faq/*. import { MessageFlags, @@ -23,13 +20,19 @@ import { } from "discord.js"; import { logger } from "../../utils/logger.js"; -// Subcommand runners (each one must NOT reply/defer on its own) import { run as runAdd } from "./subcommands/add.js"; +import { run as runGet } from "./subcommands/get.js"; +import { run as runList } from "./subcommands/list.js"; import { run as runRemove } from "./subcommands/remove.js"; -// Future: -// import { run as runGet } from "./subcommands/get.js"; -// import { run as runList } from "./subcommands/list.js"; +/** + * Slash command definition. + * + * This is the public "contract" Discord registers. + * Keep this file focused on: + * - option names/types/descriptions + * - routing to subcommand handlers + */ export const data = new SlashCommandBuilder() .setName("faq") .setDescription("FAQ commands") @@ -70,6 +73,9 @@ export const data = new SlashCommandBuilder() .addStringOption((o) => o.setName("key").setDescription("Key to fetch").setRequired(true), ) + .addBooleanOption((o) => + o.setName("full").setDescription("Show the full answer text").setRequired(false), + ) .addBooleanOption((o) => o .setName("ephemeral") @@ -84,7 +90,19 @@ export const data = new SlashCommandBuilder() .setName("list") .setDescription("List FAQ entries") .addStringOption((o) => - o.setName("tag").setDescription("Filter by a tag (optional)").setRequired(false), + o + .setName("query") + .setDescription("Search in key/title/body (optional)") + .setRequired(false), + ) + .addStringOption((o) => + o.setName("tag").setDescription("Filter by tag (optional)").setRequired(false), + ) + .addBooleanOption((o) => + o + .setName("full") + .setDescription("Show full entries (not just a compact list)") + .setRequired(false), ) .addBooleanOption((o) => o @@ -114,18 +132,15 @@ export const data = new SlashCommandBuilder() * Command execution entry point. * * Pattern: - * - Read subcommand name - * - Defer reply immediately (avoids Discord 3s timeout) - * - Route to subcommand handler - * - * IMPORTANT: - * Subcommand handlers must only use editReply (no reply/defer). + * - Determine subcommand + * - Defer reply immediately (avoids the 3s Discord timeout) + * - Route to handler */ export async function execute(interaction: ChatInputCommandInteraction): Promise { const sub = interaction.options.getSubcommand(true); const ephemeral = interaction.options.getBoolean("ephemeral") ?? false; - // Own the lifecycle here (subcommands only editReply) + // Parent owns the interaction lifecycle: always defer first. await interaction.deferReply(ephemeral ? { flags: MessageFlags.Ephemeral } : undefined); try { @@ -134,22 +149,22 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise return; } - if (sub === "remove") { - await runRemove(interaction); + if (sub === "get") { + await runGet(interaction); return; } - // Placeholders until you wire these up - if (sub === "get") { - await interaction.editReply("TODO: /faq get"); + if (sub === "list") { + await runList(interaction); return; } - if (sub === "list") { - await interaction.editReply("TODO: /faq list"); + if (sub === "remove") { + await runRemove(interaction); return; } + // Safety net in case Discord sends something unexpected await interaction.editReply("Unknown subcommand."); } catch (err) { logger.error({ err, sub }, "[faq] subcommand failed"); diff --git a/src/commands/faq/services.ts b/src/commands/faq/services.ts index 7d9a74ed..367fb53a 100644 --- a/src/commands/faq/services.ts +++ b/src/commands/faq/services.ts @@ -1,229 +1,6 @@ -// src/services/faq/services.ts +// src/commands/faq/services.ts // -// FAQ business logic layer. -// -// Responsibilities: -// - Validate + normalize inputs (keys, title/body lengths, tags) -// - Read/write the persistent store via store.ts -// - Return domain objects (FaqEntry) to command handlers -// -// Non-goals: -// - Discord interaction handling (that stays in commands) -// - Low-level file I/O details (that stays in store.ts) - -import { loadStore, saveStore } from "./store.js"; -import { logger } from "../../utils/logger.js"; -import { - MAX_KEY_LEN, - MAX_TITLE_LEN, - MAX_BODY_LEN, - MAX_TAGS, - MAX_TAG_LEN, -} from "./types.js"; -import type { FaqEntry, CreateFaqInput, UpdateFaqPatch } from "./types.js"; - -/** - * Return all FAQ entries (unordered). - */ -export function getAll(): FaqEntry[] { - const store = loadStore(); - return Object.values(store.entries); -} - -/** - * Find an entry by key. Returns null when missing. - */ -export function getByKey(rawKey: string): FaqEntry | null { - const key = assertValidKey(rawKey); - const store = loadStore(); - return store.entries[key] ?? null; -} - -/** - * Create a new FAQ entry. - * Throws if duplicate key or invalid fields. - */ -export function create(input: CreateFaqInput): FaqEntry { - const key = assertValidKey(input.key); - - const title = input.title.trim(); - if (title.length === 0) throw new Error("FAQ title cannot be empty"); - if (title.length > MAX_TITLE_LEN) { - throw new Error(`FAQ title too long (max ${MAX_TITLE_LEN})`); - } - - const body = input.body.trim(); - if (body.length === 0) throw new Error("FAQ body cannot be empty"); - if (body.length > MAX_BODY_LEN) { - throw new Error(`FAQ body too long (max ${MAX_BODY_LEN})`); - } - - const tags = normalizeTags(input.tags); - - const store = loadStore(); - if (store.entries[key]) { - throw new Error("FAQ with this key already exists"); - } - - const now = new Date().toISOString(); - - const entry: FaqEntry = { - key, - title, - body, - tags, - createdAt: now, - updatedAt: now, - createdBy: input.actor, - updatedBy: input.actor, - usageCount: 0, - }; - - store.entries[key] = entry; - saveStore(store); - - logger.info({ key, actor: input.actor }, "[faq] created"); - return entry; -} - -/** - * Update an existing FAQ entry (title/body/tags). - * Throws if key not found or patch fields invalid. - */ -export function update(rawKey: string, patch: UpdateFaqPatch): FaqEntry { - const key = assertValidKey(rawKey); - - const store = loadStore(); - const entry = store.entries[key]; - if (!entry) throw new Error(`FAQ not found: ${key}`); - - const now = new Date().toISOString(); - - // Title (optional) - if (patch.title !== undefined) { - const title = patch.title.trim(); - if (title.length === 0) throw new Error("FAQ title cannot be empty"); - if (title.length > MAX_TITLE_LEN) { - throw new Error(`FAQ title too long (max ${MAX_TITLE_LEN})`); - } - entry.title = title; - } - - // Body (optional) - if (patch.body !== undefined) { - const body = patch.body.trim(); - if (body.length === 0) throw new Error("FAQ body cannot be empty"); - if (body.length > MAX_BODY_LEN) { - throw new Error(`FAQ body too long (max ${MAX_BODY_LEN})`); - } - entry.body = body; - } - - // Tags (optional) - if (patch.tags !== undefined) { - entry.tags = normalizeTags(patch.tags); - } - - // Always update audit fields on a successful update call. - entry.updatedAt = now; - entry.updatedBy = patch.actor; - - store.entries[key] = entry; - saveStore(store); - - logger.info({ key, actor: patch.actor }, "[faq] updated"); - return entry; -} - -/** - * Remove an entry by key. - * Returns false if missing. - */ -export function remove(rawKey: string): boolean { - const key = assertValidKey(rawKey); - - const store = loadStore(); - if (!store.entries[key]) return false; - - delete store.entries[key]; - saveStore(store); - - logger.info({ key }, "[faq] removed"); - return true; -} - -/** - * Increment usage count for an entry (for analytics/ranking). - * Returns false if missing. - */ -export function incrementUsage(rawKey: string): boolean { - const key = assertValidKey(rawKey); - - const store = loadStore(); - const entry = store.entries[key]; - if (!entry) return false; - - entry.usageCount += 1; - entry.updatedAt = new Date().toISOString(); - - store.entries[key] = entry; - saveStore(store); - - return true; -} - -/* -------------------------------------------------------------------------- */ -/* Helpers */ -/* -------------------------------------------------------------------------- */ - -/** - * Normalize keys so users can type variations but store is consistent. - * Example: " How To Deploy?! " -> "how-to-deploy" - */ -function normalizeKey(rawKey: string): string { - return rawKey - .trim() - .toLowerCase() - .replace(/\s+/g, "-") - .replace(/[^a-z0-9-_]/g, ""); -} - -/** - * Validate and return a normalized key (single source of truth). - */ -function assertValidKey(rawKey: string): string { - const key = normalizeKey(rawKey); - if (key.length === 0) throw new Error("FAQ key cannot be empty"); - if (key.length > MAX_KEY_LEN) { - throw new Error(`FAQ key too long (max ${MAX_KEY_LEN})`); - } - return key; -} - -/** - * Normalize tags: - * - optional input -> [] - * - trim - * - drop empties - * - enforce per-tag max length - * - de-dupe - * - enforce max number of tags - * - * Note: casing is preserved. If you want lowercased tags, add `.toLowerCase()`. - */ -function normalizeTags(tags?: string[]): string[] { - if (!tags) return []; - - const cleaned = tags - .map((t) => t.trim()) - .filter(Boolean) - .map((t) => (t.length > MAX_TAG_LEN ? t.slice(0, MAX_TAG_LEN) : t)); - - const deduped = Array.from(new Set(cleaned)); - - if (deduped.length > MAX_TAGS) { - return deduped.slice(0, MAX_TAGS); - } +// Thin re-export so command subcommands can import from "../services.js" +// without reaching into src/services/faq directly. - return deduped; -} +export * from "../../services/faq/services.js"; diff --git a/src/commands/faq/subcommands/_shared.ts b/src/commands/faq/subcommands/_shared.ts index c5e69167..e609b684 100644 --- a/src/commands/faq/subcommands/_shared.ts +++ b/src/commands/faq/subcommands/_shared.ts @@ -1 +1,238 @@ -// src/commands/faq/subcommand/_shared.ts +// src/commands/faq/subcommands/_shared.ts +// +// Shared helpers for /faq subcommands. +// +// Goals: +// - Keep each subcommand tiny and consistent +// - Centralize common parsing, formatting, and guardrails +// - Provide one place to evolve list filtering and output style +// +// Non-goals: +// - No file I/O +// - No persistence logic +// - No Discord lifecycle (defer/reply) decisions + +import type { ChatInputCommandInteraction } from "discord.js"; +import { logger } from "../../../utils/logger.js"; + +import { canFaqAction } from "../../../services/faq/permissions.js"; +import { MAX_KEY_LEN } from "../../../services/faq/types.js"; +import type { FaqEntry } from "../../../services/faq/types.js"; + +export type FaqAction = "add" | "get" | "list" | "remove"; + +/** + * Optional list filtering and display preferences. + * + * - query: substring search across key/title/body/tags (case-insensitive) + * - tag: filter to entries that include this tag (case-insensitive) + * - limit: max entries to render + * - full: if true, render full FAQ bodies instead of one-line rows + * - sort: how to order results + */ +export type FaqListFilter = { + query?: string; + tag?: string; + limit?: number; + full?: boolean; + sort?: "usage" | "updated" | "key"; +}; + +export async function guardFaqAction( + interaction: ChatInputCommandInteraction, + action: FaqAction, +): Promise { + const perm = canFaqAction(interaction, action); + if (perm.ok) return true; + + await interaction.editReply(`❌ ${perm.reason}`); + return false; +} + +/** + * Read and minimally validate a required key option. + * + * Notes: + * - This is intentionally a light guardrail for user experience. + * - The service layer is still the source of truth for normalization/validation. + */ +export async function readRequiredKey( + interaction: ChatInputCommandInteraction, + optionName = "key", +): Promise { + const rawKey = interaction.options.getString(optionName, true); + const key = rawKey.trim(); + + if (key.length === 0) { + await interaction.editReply("❌ Key cannot be empty."); + return null; + } + + if (key.length > MAX_KEY_LEN) { + await interaction.editReply(`❌ Key is too long (max ${MAX_KEY_LEN} characters).`); + return null; + } + + return key; +} + +/** + * Parse a comma-separated tags string into a clean array. + * Example: "devops, ci/cd, bot " -> ["devops","ci/cd","bot"] + */ +export function parseTags(raw: string | null): string[] { + if (!raw) return []; + return raw + .split(",") + .map((t) => t.trim()) + .filter(Boolean); +} + +/** + * Format a single FAQ entry for Discord. + * + * Output style: + * - Key (bold) + * - Title (bold) + * - Body + * - Tags line only when present + */ +export function formatFaqEntry(entry: FaqEntry): string { + const tagsLine = entry.tags?.length ? `🏷️ ${entry.tags.join(", ")}` : null; + + return [`πŸ“Œ **${entry.key}**`, `**${entry.title}**`, entry.body, tagsLine] + .filter(Boolean) + .join("\n"); +} + +/** + * Format a list view. + * + * Default behavior: + * - Returns a scannable list of keys with a small hint (first tag, usage) + * + * If filter.full is true: + * - Returns full entry blocks (key/title/body/tags) for each match + */ +export function formatFaqList(entries: FaqEntry[], filter: FaqListFilter = {}): string { + if (entries.length === 0) { + return "No FAQs yet. Add one with `/faq add`."; + } + + const limit = typeof filter.limit === "number" ? filter.limit : 25; + + const filtered = applyListFilters(entries, filter); + if (filtered.length === 0) { + const bits: string[] = ["No FAQs matched."]; + if (filter.tag) bits.push(`Tag filter: **${filter.tag}**`); + if (filter.query) bits.push(`Query: **${filter.query}**`); + return bits.join("\n"); + } + + const sorted = applyListSort(filtered, filter.sort); + + const shown = sorted.slice(0, limit); + const remaining = sorted.length - shown.length; + + // Full mode: show each full entry block + if (filter.full) { + const blocks = shown.map((e) => formatFaqEntry(e)); + const footer = + remaining > 0 ? `\n\n…plus ${remaining} more. Narrow filters to see more.` : ""; + return [`πŸ“š **FAQ entries** (${filtered.length})`, "", ...joinBlocks(blocks), footer] + .filter(Boolean) + .join("\n"); + } + + // Compact mode: one line per entry + const lines = shown.map((e) => { + const tagHint = e.tags?.length ? ` β€” ${e.tags[0]}` : ""; + const used = typeof e.usageCount === "number" ? ` β€’ ${e.usageCount} uses` : ""; + return `β€’ **${e.key}**${tagHint}${used}`; + }); + + const moreLine = remaining > 0 ? `\n…plus ${remaining} more.` : ""; + + return [`πŸ“š **FAQ entries** (${filtered.length})`, ...lines, moreLine] + .filter(Boolean) + .join("\n"); +} + +/** + * Centralized error handler for subcommands. + * Keeps user messaging consistent and logs useful context. + */ +export async function handleFaqSubcommandError( + interaction: ChatInputCommandInteraction, + err: unknown, + tag: string, +): Promise { + logger.error({ err }, tag); + await interaction.editReply("❌ Something went wrong. Try again in a bit."); +} + +/* -------------------------------------------------------------------------- */ +/* Internal helpers */ +/* -------------------------------------------------------------------------- */ + +function applyListFilters(entries: FaqEntry[], filter: FaqListFilter): FaqEntry[] { + let out = entries; + + // Tag filter (case-insensitive exact match against any tag) + if (filter.tag && filter.tag.trim().length > 0) { + const wanted = filter.tag.trim().toLowerCase(); + out = out.filter((e) => (e.tags ?? []).some((t) => t.toLowerCase() === wanted)); + } + + // Query filter (case-insensitive substring match) + if (filter.query && filter.query.trim().length > 0) { + const q = filter.query.trim().toLowerCase(); + + out = out.filter((e) => { + const hay = [e.key, e.title, e.body, ...(e.tags ?? [])] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + return hay.includes(q); + }); + } + + return out; +} + +function applyListSort(entries: FaqEntry[], sort: FaqListFilter["sort"]): FaqEntry[] { + const mode = sort ?? "usage"; + + if (mode === "key") { + return [...entries].sort((a, b) => a.key.localeCompare(b.key)); + } + + if (mode === "updated") { + return [...entries].sort((a, b) => + (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""), + ); + } + + // default: usage desc, then updated desc, then key asc + return [...entries].sort((a, b) => { + const u = (b.usageCount ?? 0) - (a.usageCount ?? 0); + if (u !== 0) return u; + + const t = (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""); + if (t !== 0) return t; + + return a.key.localeCompare(b.key); + }); +} + +function joinBlocks(blocks: string[]): string[] { + // Add a simple divider between full entries for readability. + // Discord does not have a native horizontal rule, so we use a line. + const out: string[] = []; + blocks.forEach((b, idx) => { + if (idx > 0) out.push("────────"); + out.push(b); + }); + return out; +} diff --git a/src/commands/faq/subcommands/add.ts b/src/commands/faq/subcommands/add.ts index 3f44defc..a56bebfb 100644 --- a/src/commands/faq/subcommands/add.ts +++ b/src/commands/faq/subcommands/add.ts @@ -1,40 +1,48 @@ -// src/commands/faq/subcommand/add.ts +// src/commands/faq/subcommands/add.ts +// +// /faq add +// +// Responsibilities: +// - Read slash command options +// - Run shared guardrails (permissions, basic input checks) +// - Delegate creation to FAQ service layer +// +// IMPORTANT: +// - This file is NOT a slash command by itself +// - It must NOT call reply() or deferReply() +// - The parent command (faq.ts) owns the interaction lifecycle import type { ChatInputCommandInteraction } from "discord.js"; import { logger } from "../../../utils/logger.js"; -import { create } from "../services.js"; -import { MAX_KEY_LEN } from "../../../services/faq/types.js"; +import { create } from "../../../services/faq/services.js"; + +import { guardFaqAction, parseTags, handleFaqSubcommandError } from "./_shared.js"; export async function run(interaction: ChatInputCommandInteraction): Promise { try { - // 1) Read inputs (these must exist in faq.ts builder options) - const rawKey = interaction.options.getString("key", true); - const rawTitle = interaction.options.getString("title", true); - const rawBody = interaction.options.getString("body", true); - const rawTags = interaction.options.getString("tags", false); // optional - - // 2) Clean / normalize user input - const key = rawKey.trim(); - const title = rawTitle.trim(); - const body = rawBody.trim(); - - // 3) Validate minimal stuff here (empty, obvious length checks) - // - disallow empty key after trim - // - maybe guard title/body too - // - keep it small here, deeper rules can live in services.ts - if (key.length > MAX_KEY_LEN) { - await interaction.editReply(`❌ Key is too long (max ${MAX_KEY_LEN} characters).`); + if (!(await guardFaqAction(interaction, "add"))) return; + + const key = interaction.options.getString("key", true).trim(); + const title = interaction.options.getString("title", true).trim(); + const body = interaction.options.getString("body", true).trim(); + const tagsRaw = interaction.options.getString("tags", false); + + // Keep command-layer checks tiny. Service layer is strict. + if (!key) { + await interaction.editReply("❌ Key cannot be empty."); + return; + } + if (!title) { + await interaction.editReply("❌ Title cannot be empty."); + return; + } + if (!body) { + await interaction.editReply("❌ Body cannot be empty."); return; } - // 4) Parse tags (comma-separated) - const tags = - rawTags - ?.split(",") - .map((t) => t.trim()) - .filter(Boolean) ?? []; + const tags = parseTags(tagsRaw); - // 5) Call service (service does normalization + persistence) const entry = create({ key, title, @@ -43,12 +51,10 @@ export async function run(interaction: ChatInputCommandInteraction): Promise { + try { + if (!(await guardFaqAction(interaction, "get"))) return; + + const key = await readRequiredKey(interaction, "key"); + if (!key) return; + + const entry = getByKey(key); + if (!entry) { + await interaction.editReply(`❌ FAQ not found: **${key}**`); + return; + } + + // Optional analytics. If it fails, we still show the FAQ. + try { + incrementUsage(entry.key); + } catch { + // ignore + } + + await interaction.editReply(formatFaqEntry(entry)); + } catch (err) { + await handleFaqSubcommandError(interaction, err, "[faq/get] failed"); + } +} diff --git a/src/commands/faq/subcommands/list.ts b/src/commands/faq/subcommands/list.ts index e400e88e..c0c253ea 100644 --- a/src/commands/faq/subcommands/list.ts +++ b/src/commands/faq/subcommands/list.ts @@ -1 +1,62 @@ -// src/commands/faq/subcommand/list.ts +// src/commands/faq/subcommands/list.ts +// +// /faq list +// +// Responsibilities: +// - Enforce permissions via guardFaqAction +// - Parse lightweight UI options (query, tag, sort, full, limit) +// - Fetch entries from the FAQ service +// - Delegate filtering + formatting to _shared helpers +// +// Non-goals: +// - No persistence logic (store.ts) +// - No business rules (services.ts) +// +// Notes: +// - Keep this file thin so it stays easy to change the Discord UX later. + +import type { ChatInputCommandInteraction } from "discord.js"; +import { getAll } from "../../../services/faq/services.js"; +import { guardFaqAction, formatFaqList, handleFaqSubcommandError } from "./_shared.js"; + +export async function run(interaction: ChatInputCommandInteraction): Promise { + try { + // Guardrails first: permissions and consistent error messaging live in _shared.ts + if (!(await guardFaqAction(interaction, "list"))) return; + + // Optional filters. These options must exist in faq.ts builder to work. + const query = interaction.options.getString("query")?.trim() ?? ""; + const tag = interaction.options.getString("tag")?.trim() ?? ""; + + // Small, safe defaults to avoid Discord message spam. + const full = interaction.options.getBoolean("full") ?? false; + const limitRaw = interaction.options.getInteger("limit"); + const limit = clampInt(limitRaw ?? (full ? 5 : 25), 1, full ? 10 : 50); + + // Fetch all entries from the service layer. + // Filtering and sorting happens in formatFaqList (via _shared.ts). + const entries = getAll(); + + const text = formatFaqList(entries, { + query: query.length ? query : undefined, + tag: tag.length ? tag : undefined, + limit, + full, + }); + + await interaction.editReply(text); + } catch (err) { + await handleFaqSubcommandError(interaction, err, "[faq/list] failed"); + } +} + +/** + * Clamp an integer to a safe range. + * Keeps user input from producing huge outputs or weird negatives. + */ +function clampInt(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + if (value < min) return min; + if (value > max) return max; + return value; +} diff --git a/src/commands/faq/subcommands/remove.ts b/src/commands/faq/subcommands/remove.ts index 9de70a73..cbd4ffd7 100644 --- a/src/commands/faq/subcommands/remove.ts +++ b/src/commands/faq/subcommands/remove.ts @@ -3,73 +3,47 @@ // /faq remove // // Responsibilities: +// - Enforce permissions (via shared guard) // - Confirm intent (button) before deleting -// - Enforce basic permissions (Manage Guild or Administrator) -// - Delegate all real work to the FAQ service layer +// - Delegate all persistence + rules to the FAQ service layer // // IMPORTANT: // - This file is NOT a slash command by itself // - It must NOT call reply() or deferReply() // - The parent command (faq.ts) owns the interaction lifecycle -import type { ChatInputCommandInteraction } from "discord.js"; -import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - ComponentType, - PermissionsBitField, -} from "discord.js"; -import { logger } from "../../../utils/logger.js"; - -// NOTE: Import from the real service layer (src/services/faq/services.ts). -// This path is relative from src/commands/faq/subcommands/remove.ts - -import { getByKey, remove } from "../services.js"; +import type { ChatInputCommandInteraction, Message, ButtonInteraction } from "discord.js"; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType } from "discord.js"; -function canRemoveFaq(interaction: ChatInputCommandInteraction): boolean { - // memberPermissions is the safe v14 way (avoids the string|PermissionsBitField union) - const perms = interaction.memberPermissions; - if (!perms) return false; - - return ( - perms.has(PermissionsBitField.Flags.ManageGuild) || - perms.has(PermissionsBitField.Flags.Administrator) - ); -} +import { logger } from "../../../utils/logger.js"; +import { getByKey, remove } from "../../../services/faq/services.js"; +import { guardFaqAction, readRequiredKey, handleFaqSubcommandError } from "./_shared.js"; export async function run(interaction: ChatInputCommandInteraction): Promise { try { - // Permissions only make sense in a guild context. + // Permissions only make sense in a guild context if (!interaction.inGuild()) { await interaction.editReply("❌ `/faq remove` can only be used in a server."); return; } - if (!canRemoveFaq(interaction)) { - await interaction.editReply("❌ You do not have permission to remove FAQs."); - return; - } + // Centralized permission gate (ManageGuild/Admin or whatever your policy is) + if (!(await guardFaqAction(interaction, "remove"))) return; - const rawKey = interaction.options.getString("key", true); - const key = rawKey.trim(); - - if (key.length === 0) { - await interaction.editReply("❌ Key cannot be empty."); - return; - } + // Read + minimal validate key from options + const rawKey = await readRequiredKey(interaction, "key"); + if (!rawKey) return; - const existing = getByKey(key); + // Lookup entry (service normalizes internally too, but we keep messaging clean here) + const existing = getByKey(rawKey); if (!existing) { - await interaction.editReply(`❌ FAQ not found: **${key}**`); + await interaction.editReply(`❌ FAQ not found: **${rawKey}**`); return; } - // Use interaction.id to keep ids unique per invocation. - // The filter below ensures only the invoking user can click them. - - const confirmId = `faq_remove_confirm:${interaction.id}`; - const cancelId = `faq_remove_cancel:${interaction.id}`; + // Build a confirmation UI + const confirmId = `faq:remove:confirm:${existing.key}:${interaction.user.id}`; + const cancelId = `faq:remove:cancel:${existing.key}:${interaction.user.id}`; const row = new ActionRowBuilder().addComponents( new ButtonBuilder() @@ -82,34 +56,24 @@ export async function run(interaction: ChatInputCommandInteraction): Promise; - // Wait for the user to confirm or cancel. - // If it times out, clear buttons and exit cleanly. - - let clicked; - try { - clicked = await msg.awaitMessageComponent({ - componentType: ComponentType.Button, - time: 20_000, - filter: (i) => i.user.id === interaction.user.id, - }); - } catch { - await interaction.editReply({ - content: "⏱️ Timed out. Nothing was deleted.", - components: [], - }); - return; - } + // Wait for the requester's button click + const clicked = (await replied.awaitMessageComponent({ + componentType: ComponentType.Button, + time: 20_000, + filter: (i: ButtonInteraction) => i.user.id === interaction.user.id, + })) as ButtonInteraction; + // Cancel path if (clicked.customId === cancelId) { await clicked.update({ content: "βœ… Cancelled. Nothing was deleted.", @@ -119,7 +83,6 @@ export async function run(interaction: ChatInputCommandInteraction): Promise { + it("throws when key is empty", () => { + expect(() => + create({ + key: " ", + title: "Test title", + body: "Test body", + actor: "test-user", + }), + ).toThrow(); + }); +}); diff --git a/src/services/faq/services.ts b/src/services/faq/services.ts new file mode 100644 index 00000000..539ba8f8 --- /dev/null +++ b/src/services/faq/services.ts @@ -0,0 +1,233 @@ +// src/services/faq/services.ts +// +// FAQ business logic layer. +// +// Responsibilities: +// - Validate + normalize inputs (keys, title/body lengths, tags) +// - Read/write the persistent store via store.ts +// - Return domain objects (FaqEntry) to command handlers +// +// Non-goals: +// - Discord interaction handling (that stays in commands) +// - Low-level file I/O details (that stays in store.ts) + +import { loadStore, saveStore } from "../../commands/faq/store.js"; +import { logger } from "../../utils/logger.js"; +import { + MAX_KEY_LEN, + MAX_TITLE_LEN, + MAX_BODY_LEN, + MAX_TAGS, + MAX_TAG_LEN, +} from "../../commands/faq/types.js"; +import type { + FaqEntry, + CreateFaqInput, + UpdateFaqPatch, +} from "../../commands/faq/types.js"; + +/** + * Return all FAQ entries (unordered). + */ +export function getAll(): FaqEntry[] { + const store = loadStore(); + return Object.values(store.entries); +} + +/** + * Find an entry by key. Returns null when missing. + */ +export function getByKey(rawKey: string): FaqEntry | null { + const key = assertValidKey(rawKey); + const store = loadStore(); + return store.entries[key] ?? null; +} + +/** + * Create a new FAQ entry. + * Throws if duplicate key or invalid fields. + */ +export function create(input: CreateFaqInput): FaqEntry { + const key = assertValidKey(input.key); + + const title = input.title.trim(); + if (title.length === 0) throw new Error("FAQ title cannot be empty"); + if (title.length > MAX_TITLE_LEN) { + throw new Error(`FAQ title too long (max ${MAX_TITLE_LEN})`); + } + + const body = input.body.trim(); + if (body.length === 0) throw new Error("FAQ body cannot be empty"); + if (body.length > MAX_BODY_LEN) { + throw new Error(`FAQ body too long (max ${MAX_BODY_LEN})`); + } + + const tags = normalizeTags(input.tags); + + const store = loadStore(); + if (store.entries[key]) { + throw new Error("FAQ with this key already exists"); + } + + const now = new Date().toISOString(); + + const entry: FaqEntry = { + key, + title, + body, + tags, + createdAt: now, + updatedAt: now, + createdBy: input.actor, + updatedBy: input.actor, + usageCount: 0, + }; + + store.entries[key] = entry; + saveStore(store); + + logger.info({ key, actor: input.actor }, "[faq] created"); + return entry; +} + +/** + * Update an existing FAQ entry (title/body/tags). + * Throws if key not found or patch fields invalid. + */ +export function update(rawKey: string, patch: UpdateFaqPatch): FaqEntry { + const key = assertValidKey(rawKey); + + const store = loadStore(); + const entry = store.entries[key]; + if (!entry) throw new Error(`FAQ not found: ${key}`); + + const now = new Date().toISOString(); + + // Title (optional) + if (patch.title !== undefined) { + const title = patch.title.trim(); + if (title.length === 0) throw new Error("FAQ title cannot be empty"); + if (title.length > MAX_TITLE_LEN) { + throw new Error(`FAQ title too long (max ${MAX_TITLE_LEN})`); + } + entry.title = title; + } + + // Body (optional) + if (patch.body !== undefined) { + const body = patch.body.trim(); + if (body.length === 0) throw new Error("FAQ body cannot be empty"); + if (body.length > MAX_BODY_LEN) { + throw new Error(`FAQ body too long (max ${MAX_BODY_LEN})`); + } + entry.body = body; + } + + // Tags (optional) + if (patch.tags !== undefined) { + entry.tags = normalizeTags(patch.tags); + } + + // Always update audit fields on a successful update call. + entry.updatedAt = now; + entry.updatedBy = patch.actor; + + store.entries[key] = entry; + saveStore(store); + + logger.info({ key, actor: patch.actor }, "[faq] updated"); + return entry; +} + +/** + * Remove an entry by key. + * Returns false if missing. + */ +export function remove(rawKey: string): boolean { + const key = assertValidKey(rawKey); + + const store = loadStore(); + if (!store.entries[key]) return false; + + delete store.entries[key]; + saveStore(store); + + logger.info({ key }, "[faq] removed"); + return true; +} + +/** + * Increment usage count for an entry (for analytics/ranking). + * Returns false if missing. + */ +export function incrementUsage(rawKey: string): boolean { + const key = assertValidKey(rawKey); + + const store = loadStore(); + const entry = store.entries[key]; + if (!entry) return false; + + entry.usageCount += 1; + entry.updatedAt = new Date().toISOString(); + + store.entries[key] = entry; + saveStore(store); + + return true; +} + +/* -------------------------------------------------------------------------- */ +/* Helpers */ +/* -------------------------------------------------------------------------- */ + +/** + * Normalize keys so users can type variations but store is consistent. + * Example: " How To Deploy?! " -> "how-to-deploy" + */ +function normalizeKey(rawKey: string): string { + return rawKey + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-_]/g, ""); +} + +/** + * Validate and return a normalized key (single source of truth). + */ +function assertValidKey(rawKey: string): string { + const key = normalizeKey(rawKey); + if (key.length === 0) throw new Error("FAQ key cannot be empty"); + if (key.length > MAX_KEY_LEN) { + throw new Error(`FAQ key too long (max ${MAX_KEY_LEN})`); + } + return key; +} + +/** + * Normalize tags: + * - optional input -> [] + * - trim + * - drop empties + * - enforce per-tag max length + * - de-dupe + * - enforce max number of tags + * + * Note: casing is preserved. If you want lowercased tags, add `.toLowerCase()`. + */ +function normalizeTags(tags?: string[]): string[] { + if (!tags) return []; + + const cleaned = tags + .map((t) => t.trim()) + .filter(Boolean) + .map((t) => (t.length > MAX_TAG_LEN ? t.slice(0, MAX_TAG_LEN) : t)); + + const deduped = Array.from(new Set(cleaned)); + + if (deduped.length > MAX_TAGS) { + return deduped.slice(0, MAX_TAGS); + } + + return deduped; +} diff --git a/src/services/faq/store.test.ts b/src/services/faq/store.test.ts new file mode 100644 index 00000000..d2be28ed --- /dev/null +++ b/src/services/faq/store.test.ts @@ -0,0 +1,115 @@ +// src/services/faq/store.test.ts +// +// Tests for the FAQ persistence layer (store.ts). +// +// Goals: +// - Never touch your real repo ./data folder +// - Exercise real filesystem behavior in an isolated temp directory +// - Verify safe fallbacks when the store file is missing or malformed + +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; +import fs from "fs"; +import path from "path"; +import os from "os"; + +type StoreModule = typeof import("./store.js"); + +let originalCwd = ""; +let tempRoot = ""; +let storePath = ""; + +/** + * Import store.ts AFTER we switch cwd. + * store.ts computes STORE_PATH at import time using process.cwd(). + */ +async function importStoreFresh(): Promise { + vi.resetModules(); + return await import("./store.js"); +} + +function rmIfExists(p: string): void { + if (!fs.existsSync(p)) return; + fs.rmSync(p, { recursive: true, force: true }); +} + +beforeAll(() => { + originalCwd = process.cwd(); + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omegabot-faq-store-")); + process.chdir(tempRoot); + + storePath = path.join(process.cwd(), "data", "faqs.json"); +}); + +afterAll(() => { + process.chdir(originalCwd); + rmIfExists(tempRoot); +}); + +beforeEach(() => { + // Each test starts with a clean tempRoot/data folder. + rmIfExists(path.join(process.cwd(), "data")); +}); + +describe("faq store", () => { + it("creates the store file if missing", async () => { + const { loadStore } = await importStoreFresh(); + + expect(fs.existsSync(storePath)).toBe(false); + + const store = loadStore(); + + expect(fs.existsSync(storePath)).toBe(true); + expect(store.version).toBe(1); + expect(store.entries).toEqual({}); + }); + + it("falls back to empty store when JSON is malformed", async () => { + const { loadStore, ensureStoreFile } = await importStoreFresh(); + + ensureStoreFile(); + fs.writeFileSync(storePath, "not json", "utf8"); + + const store = loadStore(); + + expect(store.version).toBe(1); + expect(store.entries).toEqual({}); + }); + + it("falls back to empty store when shape is invalid", async () => { + const { loadStore, ensureStoreFile } = await importStoreFresh(); + + ensureStoreFile(); + + // Wrong version + missing entries + fs.writeFileSync(storePath, JSON.stringify({ version: 999 }, null, 2), "utf8"); + + const store = loadStore(); + + expect(store.version).toBe(1); + expect(store.entries).toEqual({}); + }); + + it("saveStore writes valid JSON that loadStore can read back", async () => { + const { loadStore, saveStore } = await importStoreFresh(); + + const store = loadStore(); + + store.entries["hello"] = { + key: "hello", + title: "Hello", + body: "World", + tags: ["test"], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdBy: "system", + updatedBy: "system", + usageCount: 0, + }; + + saveStore(store); + + const reread = loadStore(); + expect(reread.entries["hello"]?.title).toBe("Hello"); + expect(reread.entries["hello"]?.tags).toEqual(["test"]); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..000bcfc6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + clearMocks: true, + }, +});