commit eace5d0f35a8cafbfda920939489e5888c848384 Author: Max Litruv Boonzaayer Date: Sat Apr 18 20:09:16 2026 +1000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4e2c6d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..02cc2d9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,560 @@ +{ + "name": "@paarrot/plugin-manager", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@paarrot/plugin-manager", + "version": "1.0.0", + "devDependencies": { + "rimraf": "^5.0.0", + "typescript": "^5.4.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4b5ad78 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "@paarrot/plugin-manager", + "version": "1.0.0", + "description": "Framework-agnostic plugin system for Paarrot applications.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "clean": "rimraf dist" + }, + "devDependencies": { + "typescript": "^5.4.0", + "rimraf": "^5.0.0" + } +} diff --git a/src/PluginContext.ts b/src/PluginContext.ts new file mode 100644 index 0000000..67be6e8 --- /dev/null +++ b/src/PluginContext.ts @@ -0,0 +1,186 @@ +import type { PluginContext, NotificationOptions } from './PluginInterfaces.js'; +import type { IPluginEventClient } from './interfaces.js'; +import type { PluginRegistry } from './PluginRegistry.js'; + +/** + * Options for constructing a PluginContext via `createPluginContext`. + */ +export interface PluginContextOptions { + /** The plugin's unique identifier. */ + pluginId: string; + + /** + * Optional event client for the `context.events` API. + * Any object implementing `on(event, handler)` / `off(event, handler)` works — + * e.g. a MatrixClient, Node.js EventEmitter, or socket.io socket. + */ + eventClient?: IPluginEventClient; + + /** + * Called when a plugin invokes `context.notify(...)`. + * The host is responsible for displaying the notification however it wants. + */ + onNotify?: (options: NotificationOptions) => void | Promise; +} + +/** + * Creates a PluginContext wired to the given registry. + * Call this before registering and loading a plugin. + * + * @param options - Host-specific dependencies for the plugin. + * @param registry - The shared PluginRegistry instance. + * @returns A fully-wired PluginContext ready to be passed to `plugin.onLoad`. + */ +export function createPluginContext( + options: PluginContextOptions, + registry: PluginRegistry +): PluginContext { + const { pluginId, eventClient, onNotify } = options; + + return { + pluginId, + + commands: { + register: (command) => { + registry.registerCommand(pluginId, command); + registry.addLog(pluginId, 'log', [`Registered command: /${command.name}`]); + }, + unregister: (name) => { + registry.unregisterCommand(name); + }, + execute: async (name, args) => { + return registry.executeCommand(name, JSON.stringify(args)); + }, + }, + + messages: { + onBeforeSend: (interceptor) => { + registry.addBeforeSendInterceptor(pluginId, interceptor); + registry.addLog(pluginId, 'log', ['Registered beforeSend interceptor']); + }, + onReceive: (interceptor) => { + registry.addReceiveInterceptor(pluginId, interceptor); + registry.addLog(pluginId, 'log', ['Registered receive interceptor']); + }, + }, + + ui: { + registerRenderer: (type, renderer) => { + registry.registerRenderer(pluginId, type, renderer); + registry.addLog(pluginId, 'log', [`Registered renderer: ${type}`]); + }, + unregisterRenderer: (type) => { + registry.unregisterRenderer(type); + }, + }, + + settings: { + define: (schema) => { + registry.defineSettings(pluginId, schema); + registry.addLog(pluginId, 'log', ['Defined settings schema']); + }, + get: (key: string): T | undefined => { + return registry.getPluginSetting(pluginId, key); + }, + set: (key, value) => { + registry.setPluginSetting(pluginId, key, value); + }, + }, + + themes: { + register: (theme) => { + registry.registerTheme(pluginId, theme); + registry.addLog(pluginId, 'log', [`Registered theme: ${theme.name}`]); + }, + unregister: (themeId) => { + registry.unregisterTheme(pluginId, themeId); + }, + }, + + events: { + on: (eventType, handler) => { + if (!eventClient) { + console.warn(`[Plugin ${pluginId}] No event client provided — events.on() is a no-op`); + return; + } + registry.addEventHandler(pluginId, eventType, handler); + eventClient.on(eventType, handler); + registry.addLog(pluginId, 'log', [`Listening to event: ${eventType}`]); + }, + off: (eventType, handler) => { + if (!eventClient) return; + registry.removeEventHandler(pluginId, eventType, handler); + eventClient.off(eventType, handler); + }, + }, + + timers: { + setInterval: (callback, ms) => { + const id = setInterval(() => { + try { + callback(); + } catch (err) { + registry.addLog(pluginId, 'error', ['Interval error:', err]); + } + }, ms) as unknown as number; + registry.addTimer(pluginId, id); + return id; + }, + setTimeout: (callback, ms) => { + const id = setTimeout(() => { + try { + callback(); + } catch (err) { + registry.addLog(pluginId, 'error', ['Timeout error:', err]); + } + registry.removeTimer(pluginId, id); + }, ms) as unknown as number; + registry.addTimer(pluginId, id); + return id; + }, + clearInterval: (id) => { + clearInterval(id); + registry.removeTimer(pluginId, id); + }, + clearTimeout: (id) => { + clearTimeout(id); + registry.removeTimer(pluginId, id); + }, + }, + + notify: async (options) => { + const opts: NotificationOptions = + typeof options === 'string' ? { title: 'Plugin', body: options } : options; + registry.addLog(pluginId, 'log', ['Notification:', opts]); + if (onNotify) { + try { + await onNotify(opts); + } catch (err) { + console.error(`[Plugin ${pluginId}] Notification handler error:`, err); + } + } + }, + + log: (...args) => { + console.log(`[Plugin ${pluginId}]`, ...args); + registry.addLog(pluginId, 'log', args); + }, + warn: (...args) => { + console.warn(`[Plugin ${pluginId}]`, ...args); + registry.addLog(pluginId, 'warn', args); + }, + error: (...args) => { + console.error(`[Plugin ${pluginId}]`, ...args); + registry.addLog(pluginId, 'error', args); + }, + + require: (requiredPluginId) => { + const exports = registry.getPluginExports(requiredPluginId); + if (exports === undefined) { + throw new Error(`Plugin ${requiredPluginId} not found or has no exports`); + } + registry.addLog(pluginId, 'log', [`Required plugin: ${requiredPluginId}`]); + return exports; + }, + }; +} diff --git a/src/PluginInterfaces.ts b/src/PluginInterfaces.ts new file mode 100644 index 0000000..3b07bbb --- /dev/null +++ b/src/PluginInterfaces.ts @@ -0,0 +1,242 @@ +/** + * Color group with five container variants and an OnContainer text color. + */ +export interface ThemeColorGroup { + container: string; + containerHover: string; + containerActive: string; + containerLine: string; + onContainer: string; +} + +/** + * Color group for interactive palette entries (Main + Container sub-groups). + */ +export interface ThemePaletteGroup { + main: string; + mainHover: string; + mainActive: string; + mainLine: string; + onMain: string; + container: string; + containerHover: string; + containerActive: string; + containerLine: string; + onContainer: string; +} + +/** + * Full color token set for a plugin theme. + * Property names map directly to the folds design-token contract. + */ +export interface PluginThemeColors { + background: ThemeColorGroup; + surface: ThemeColorGroup; + surfaceVariant: ThemeColorGroup; + primary: ThemePaletteGroup; + secondary: ThemePaletteGroup; + success: ThemePaletteGroup; + warning: ThemePaletteGroup; + critical: ThemePaletteGroup; + other: { + focusRing: string; + shadow: string; + overlay: string; + }; +} + +/** + * Theme definition supplied by a plugin. + */ +export interface PluginTheme { + id: string; + name: string; + kind: 'light' | 'dark'; + colors: PluginThemeColors; + /** Optional extra CSS injected alongside the theme variables. */ + extraCss?: string; +} + +/** + * A command argument definition. + */ +export interface CommandArg { + name: string; + type?: 'string' | 'number' | 'boolean'; + required?: boolean; + description?: string; +} + +/** + * A command registered by a plugin. + */ +export interface PluginCommand { + name: string; + description?: string; + args?: string[] | CommandArg[]; + run: (args: Record, ctx: PluginContext) => string | void | Promise; +} + +/** Callback invoked for outgoing or incoming messages. */ +export type MessageInterceptor = (msg: MessageContext) => void | Promise; + +/** + * Message context passed to interceptors. + */ +export interface MessageContext { + content: string; + roomId: string; + eventType: string; + formatted?: string; + metadata?: Record; +} + +/** + * Custom renderer function. + * The return type is `unknown` so the package stays framework-agnostic. + * Hosts using React should cast to `ReactNode` at the call-site. + */ +export type CustomRenderer = (data: unknown, defaultRenderer?: () => unknown) => unknown; + +/** + * A single setting definition within a plugin's settings schema. + */ +export interface SettingDefinition { + type: 'string' | 'number' | 'boolean' | 'select' | 'color'; + label?: string; + description?: string; + default?: unknown; + options?: Array<{ value: unknown; label: string }>; +} + +/** The full settings schema for a plugin. */ +export type SettingsSchema = Record; + +/** + * Options for a notification emitted by a plugin. + */ +export interface NotificationOptions { + title: string; + body: string; + type?: 'info' | 'success' | 'error' | 'warning'; + duration?: number; + actions?: Array<{ label: string; action: () => void }>; +} + +/** + * The context provided to plugins when they are initialized. + * All APIs are available through this object. + */ +export interface PluginContext { + /** Plugin ID */ + pluginId: string; + + /** Commands API */ + commands: { + register: (command: PluginCommand) => void; + unregister: (name: string) => void; + execute: (name: string, args: Record) => Promise; + }; + + /** Messages API */ + messages: { + onBeforeSend: (interceptor: MessageInterceptor) => void; + onReceive: (interceptor: MessageInterceptor) => void; + }; + + /** UI API — framework-agnostic renderer registry */ + ui: { + registerRenderer: (type: string, renderer: CustomRenderer) => void; + unregisterRenderer: (type: string) => void; + }; + + /** Settings API */ + settings: { + define: (schema: SettingsSchema) => void; + get: (key: string) => T | undefined; + set: (key: string, value: unknown) => void; + }; + + /** Themes API */ + themes: { + register: (theme: PluginTheme) => void; + unregister: (themeId: string) => void; + }; + + /** + * Generic event emitter API. + * Wire up any event client (Matrix, socket.io, Node EventEmitter, etc.) + * via the `eventClient` option passed to `createPluginContext`. + */ + events: { + on: (eventType: string, handler: (...args: any[]) => void) => void; + off: (eventType: string, handler: (...args: any[]) => void) => void; + }; + + /** Background tasks API */ + timers: { + setInterval: (callback: () => void, ms: number) => number; + setTimeout: (callback: () => void, ms: number) => number; + clearInterval: (id: number) => void; + clearTimeout: (id: number) => void; + }; + + /** Send a notification through the host application. */ + notify: (options: NotificationOptions | string) => void | Promise; + + /** Logging API */ + log: (...args: any[]) => void; + warn: (...args: any[]) => void; + error: (...args: any[]) => void; + + /** Require another loaded plugin's exports. */ + require: (pluginId: string) => unknown; +} + +/** + * Custom settings section that a plugin can inject into the settings UI. + * The `component` value is `unknown` to remain framework-agnostic. + * Hosts using React should treat it as `ReactNode`. + */ +export interface PluginSettingsSection { + id: string; + name: string; + icon?: string; + component: unknown; +} + +/** UI locations where plugins can inject content. */ +export type UILocation = + | 'room-header' + | 'message-actions' + | 'user-menu' + | 'room-menu' + | 'composer-actions'; + +/** + * The main plugin interface that all plugins must implement. + */ +export interface Plugin { + name?: string; + version?: string; + dependencies?: Record; + + /** Called when the plugin is loaded. */ + onLoad: (context: PluginContext) => void | Promise; + + /** Called when the plugin is unloaded or disabled. */ + onUnload?: () => void | Promise; + + /** Exposed API surface for other plugins to consume via `context.require()`. */ + exports?: unknown; +} + +/** + * A single log entry produced by a plugin. + */ +export interface PluginLogEntry { + pluginId: string; + level: 'log' | 'warn' | 'error'; + timestamp: number; + args: unknown[]; +} diff --git a/src/PluginRegistry.ts b/src/PluginRegistry.ts new file mode 100644 index 0000000..6cd4bb2 --- /dev/null +++ b/src/PluginRegistry.ts @@ -0,0 +1,429 @@ +import type { + Plugin, + PluginContext, + PluginCommand, + MessageInterceptor, + MessageContext, + CustomRenderer, + SettingsSchema, + PluginTheme, + PluginLogEntry, +} from './PluginInterfaces.js'; +import type { IPluginStorage, IPluginEventClient } from './interfaces.js'; +import { MemoryStorage } from './interfaces.js'; +import { generateThemeCSS } from './theme-css.js'; + +/** + * Configuration options for the PluginRegistry. + */ +export interface PluginRegistryOptions { + /** + * Storage adapter for persisting plugin settings. + * Defaults to in-memory storage if not provided. + * Pass `localStorage` (or a wrapper) for browser environments. + */ + storage?: IPluginStorage; + + /** + * Called when a plugin registers a theme. + * The host is responsible for injecting the CSS into the document. + * Receives the full theme ID (used as the CSS class name) and the generated CSS string. + */ + onThemeRegistered?: (themeId: string, className: string, css: string) => void; + + /** + * Called when a plugin unregisters a theme. + * The host is responsible for removing the injected CSS. + */ + onThemeUnregistered?: (themeId: string) => void; +} + +/** + * Central registry that manages all loaded plugins and their side-effects. + * Create a single instance and share it across your application. + */ +export class PluginRegistry { + private readonly plugins = new Map(); + private readonly commands = new Map(); + private beforeSendInterceptors: Array<{ pluginId: string; interceptor: MessageInterceptor }> = []; + private receiveInterceptors: Array<{ pluginId: string; interceptor: MessageInterceptor }> = []; + private readonly renderers = new Map(); + private readonly settingsSchemas = new Map(); + private readonly pluginSettings = new Map>(); + private readonly matrixHandlers = new Map< + string, + Array<{ pluginId: string; handler: (...args: any[]) => void }> + >(); + private readonly timers = new Map(); + private readonly themes = new Map< + string, + { pluginId: string; theme: PluginTheme } + >(); + private logs: PluginLogEntry[] = []; + private readonly maxLogs = 1000; + private readonly storage: IPluginStorage; + private readonly onThemeRegistered?: PluginRegistryOptions['onThemeRegistered']; + private readonly onThemeUnregistered?: PluginRegistryOptions['onThemeUnregistered']; + + constructor(options: PluginRegistryOptions = {}) { + this.storage = options.storage ?? new MemoryStorage(); + this.onThemeRegistered = options.onThemeRegistered; + this.onThemeUnregistered = options.onThemeUnregistered; + } + + // --------------------------------------------------------------------------- + // Plugin lifecycle + // --------------------------------------------------------------------------- + + /** Register a plugin and its context. Called after the host has loaded the module. */ + registerPlugin(id: string, plugin: Plugin, context: PluginContext): void { + if (this.plugins.has(id)) { + console.warn(`[PluginRegistry] Plugin ${id} is already registered`); + return; + } + this.plugins.set(id, { plugin, context }); + this.timers.set(id, []); + } + + /** Unregister a plugin, calling its onUnload hook and cleaning up all side-effects. */ + async unregisterPlugin(id: string): Promise { + const entry = this.plugins.get(id); + if (!entry) return; + + if (entry.plugin.onUnload) { + try { + await entry.plugin.onUnload(); + } catch (err) { + console.error(`[PluginRegistry] Error unloading plugin ${id}:`, err); + } + } + + for (const [cmdName, cmd] of this.commands.entries()) { + if (cmd.pluginId === id) this.commands.delete(cmdName); + } + + this.beforeSendInterceptors = this.beforeSendInterceptors.filter(i => i.pluginId !== id); + this.receiveInterceptors = this.receiveInterceptors.filter(i => i.pluginId !== id); + + for (const [type, renderer] of this.renderers.entries()) { + if (renderer.pluginId === id) this.renderers.delete(type); + } + + for (const [themeId, theme] of this.themes.entries()) { + if (theme.pluginId === id) { + this.onThemeUnregistered?.(themeId); + this.themes.delete(themeId); + } + } + + const timerIds = this.timers.get(id) ?? []; + timerIds.forEach(timerId => clearInterval(timerId)); + this.timers.delete(id); + + for (const [eventType, handlers] of this.matrixHandlers.entries()) { + const filtered = handlers.filter(h => h.pluginId !== id); + if (filtered.length === 0) { + this.matrixHandlers.delete(eventType); + } else { + this.matrixHandlers.set(eventType, filtered); + } + } + + this.plugins.delete(id); + } + + // --------------------------------------------------------------------------- + // Commands + // --------------------------------------------------------------------------- + + /** Register a command contributed by a plugin. */ + registerCommand(pluginId: string, command: PluginCommand): void { + if (this.commands.has(command.name)) { + console.warn(`[PluginRegistry] Command /${command.name} already exists`); + return; + } + this.commands.set(command.name, { pluginId, command }); + } + + /** Remove a registered command by name. */ + unregisterCommand(name: string): void { + this.commands.delete(name); + } + + /** Execute a command by name, passing raw argument string. */ + async executeCommand(name: string, rawArgs: string): Promise { + const cmd = this.commands.get(name); + if (!cmd) throw new Error(`Command /${name} not found`); + + const args = this.parseCommandArgs(cmd.command, rawArgs); + const entry = this.plugins.get(cmd.pluginId); + if (!entry) throw new Error(`Plugin ${cmd.pluginId} not loaded`); + + return await cmd.command.run(args, entry.context); + } + + private parseCommandArgs(command: PluginCommand, rawArgs: string): Record { + if (!command.args || command.args.length === 0) return {}; + const parts = rawArgs.trim().split(/\s+/); + const result: Record = {}; + command.args.forEach((arg, index) => { + const argName = typeof arg === 'string' ? arg : arg.name; + result[argName] = parts[index]; + }); + return result; + } + + /** Return all registered commands. */ + getCommands(): Array<{ name: string; pluginId: string; command: PluginCommand }> { + return Array.from(this.commands.entries()).map(([name, data]) => ({ name, ...data })); + } + + // --------------------------------------------------------------------------- + // Message interceptors + // --------------------------------------------------------------------------- + + /** Add a before-send interceptor for a plugin. */ + addBeforeSendInterceptor(pluginId: string, interceptor: MessageInterceptor): void { + this.beforeSendInterceptors.push({ pluginId, interceptor }); + } + + /** Add a receive interceptor for a plugin. */ + addReceiveInterceptor(pluginId: string, interceptor: MessageInterceptor): void { + this.receiveInterceptors.push({ pluginId, interceptor }); + } + + /** Run a message through all before-send interceptors in registration order. */ + async processBeforeSend(msg: MessageContext): Promise { + let processed = { ...msg }; + for (const { interceptor } of this.beforeSendInterceptors) { + try { + await interceptor(processed); + } catch (err) { + console.error('[PluginRegistry] Error in beforeSend interceptor:', err); + } + } + return processed; + } + + /** Run a message through all receive interceptors in registration order. */ + async processReceive(msg: MessageContext): Promise { + let processed = { ...msg }; + for (const { interceptor } of this.receiveInterceptors) { + try { + await interceptor(processed); + } catch (err) { + console.error('[PluginRegistry] Error in receive interceptor:', err); + } + } + return processed; + } + + // --------------------------------------------------------------------------- + // Renderers + // --------------------------------------------------------------------------- + + /** Register a custom renderer contributed by a plugin. */ + registerRenderer(pluginId: string, type: string, renderer: CustomRenderer): void { + this.renderers.set(type, { pluginId, renderer }); + } + + /** Remove a renderer by type. */ + unregisterRenderer(type: string): void { + this.renderers.delete(type); + } + + /** Get the renderer for a given type, if any. */ + getRenderer(type: string): CustomRenderer | undefined { + return this.renderers.get(type)?.renderer; + } + + // --------------------------------------------------------------------------- + // Settings + // --------------------------------------------------------------------------- + + /** Define the settings schema for a plugin and load persisted values. */ + defineSettings(pluginId: string, schema: SettingsSchema): void { + this.settingsSchemas.set(pluginId, schema); + + const stored = this.storage.getItem(`plugin:${pluginId}:settings`); + if (stored) { + try { + this.pluginSettings.set(pluginId, JSON.parse(stored)); + } catch (err) { + console.error(`[PluginRegistry] Failed to load settings for ${pluginId}:`, err); + } + } else { + const defaults: Record = {}; + for (const [key, def] of Object.entries(schema)) { + if (def.default !== undefined) defaults[key] = def.default; + } + this.pluginSettings.set(pluginId, defaults); + } + } + + /** Get a plugin setting value. */ + getPluginSetting(pluginId: string, key: string): T | undefined { + return this.pluginSettings.get(pluginId)?.[key] as T | undefined; + } + + /** Set a plugin setting value and persist it. */ + setPluginSetting(pluginId: string, key: string, value: unknown): void { + const settings = this.pluginSettings.get(pluginId) ?? {}; + settings[key] = value; + this.pluginSettings.set(pluginId, settings); + this.storage.setItem(`plugin:${pluginId}:settings`, JSON.stringify(settings)); + } + + /** Get the settings schema for a plugin. */ + getPluginSettingsSchema(pluginId: string): SettingsSchema | undefined { + return this.settingsSchemas.get(pluginId); + } + + // --------------------------------------------------------------------------- + // Themes + // --------------------------------------------------------------------------- + + /** Register a theme contributed by a plugin. */ + registerTheme(pluginId: string, theme: PluginTheme): void { + const fullId = `plugin-${pluginId}-${theme.id}`; + if (this.themes.has(fullId)) { + console.warn(`[PluginRegistry] Theme ${fullId} already registered`); + return; + } + + const css = generateThemeCSS(fullId, theme); + this.themes.set(fullId, { pluginId, theme: { ...theme, id: fullId } }); + this.onThemeRegistered?.(fullId, fullId, css); + console.log(`[PluginRegistry] Theme registered: ${fullId}`); + } + + /** Unregister a theme contributed by a plugin. */ + unregisterTheme(pluginId: string, themeId: string): void { + const fullId = `plugin-${pluginId}-${themeId}`; + if (this.themes.has(fullId)) { + this.onThemeUnregistered?.(fullId); + this.themes.delete(fullId); + } + } + + /** Return all registered plugin themes. */ + getPluginThemes(): Array<{ id: string; name: string; kind: 'light' | 'dark'; className: string }> { + return Array.from(this.themes.values()).map(({ theme }) => ({ + id: theme.id, + name: theme.name, + kind: theme.kind, + className: theme.id, + })); + } + + // --------------------------------------------------------------------------- + // Event handlers (generic — wire to Matrix, socket.io, etc.) + // --------------------------------------------------------------------------- + + /** Track an event handler registered by a plugin. */ + addEventHandler(pluginId: string, eventType: string, handler: (...args: any[]) => void): void { + if (!this.matrixHandlers.has(eventType)) { + this.matrixHandlers.set(eventType, []); + } + this.matrixHandlers.get(eventType)!.push({ pluginId, handler }); + } + + /** Remove a previously tracked event handler. */ + removeEventHandler( + pluginId: string, + eventType: string, + handler: (...args: any[]) => void + ): void { + const handlers = this.matrixHandlers.get(eventType); + if (handlers) { + const filtered = handlers.filter(h => h.pluginId !== pluginId || h.handler !== handler); + this.matrixHandlers.set(eventType, filtered); + } + } + + /** Get all tracked handlers for an event type. */ + getEventHandlers(eventType: string): Array<{ pluginId: string; handler: (...args: any[]) => void }> { + return this.matrixHandlers.get(eventType) ?? []; + } + + // --------------------------------------------------------------------------- + // Timers + // --------------------------------------------------------------------------- + + /** Track a timer ID for a plugin so it can be cleared on unload. */ + addTimer(pluginId: string, timerId: number): void { + const timers = this.timers.get(pluginId) ?? []; + timers.push(timerId); + this.timers.set(pluginId, timers); + } + + /** Remove a timer ID from tracking (e.g. after manual clearInterval). */ + removeTimer(pluginId: string, timerId: number): void { + const timers = this.timers.get(pluginId) ?? []; + this.timers.set(pluginId, timers.filter(id => id !== timerId)); + } + + // --------------------------------------------------------------------------- + // Logging + // --------------------------------------------------------------------------- + + /** Append a log entry from a plugin. */ + addLog(pluginId: string, level: 'log' | 'warn' | 'error', args: unknown[]): void { + this.logs.push({ pluginId, level, timestamp: Date.now(), args }); + if (this.logs.length > this.maxLogs) { + this.logs = this.logs.slice(-this.maxLogs); + } + } + + /** Return log entries, optionally filtered to a specific plugin. */ + getLogs(pluginId?: string): PluginLogEntry[] { + return pluginId ? this.logs.filter(l => l.pluginId === pluginId) : [...this.logs]; + } + + /** Clear log entries, optionally scoped to a specific plugin. */ + clearLogs(pluginId?: string): void { + this.logs = pluginId ? this.logs.filter(l => l.pluginId !== pluginId) : []; + } + + // --------------------------------------------------------------------------- + // Plugin exports (require()) + // --------------------------------------------------------------------------- + + /** Get the public exports of a loaded plugin. */ + getPluginExports(pluginId: string): unknown { + return this.plugins.get(pluginId)?.plugin.exports; + } + + // --------------------------------------------------------------------------- + // Hot reload + // --------------------------------------------------------------------------- + + /** Unload the current plugin instance and load a new one in-place. */ + async reloadPlugin(pluginId: string, newPlugin: Plugin, newContext: PluginContext): Promise { + await this.unregisterPlugin(pluginId); + this.registerPlugin(pluginId, newPlugin, newContext); + await newPlugin.onLoad(newContext); + } + + // --------------------------------------------------------------------------- + // Teardown + // --------------------------------------------------------------------------- + + /** Unload all plugins and clear all registry state. */ + clear(): void { + for (const pluginId of this.plugins.keys()) { + this.unregisterPlugin(pluginId).catch(err => { + console.error(`[PluginRegistry] Error clearing plugin ${pluginId}:`, err); + }); + } + this.plugins.clear(); + this.commands.clear(); + this.beforeSendInterceptors = []; + this.receiveInterceptors = []; + this.renderers.clear(); + this.settingsSchemas.clear(); + this.matrixHandlers.clear(); + this.timers.clear(); + this.logs = []; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5c8f381 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,40 @@ +export type { + IPluginStorage, + IPluginEventClient, +} from './interfaces.js'; +export { MemoryStorage } from './interfaces.js'; + +export type { + PluginMetadata, + PluginIndex, + InstalledPlugin, +} from './types.js'; +export { PluginTab } from './types.js'; + +export type { + ThemeColorGroup, + ThemePaletteGroup, + PluginThemeColors, + PluginTheme, + CommandArg, + PluginCommand, + MessageInterceptor, + MessageContext, + CustomRenderer, + SettingDefinition, + SettingsSchema, + NotificationOptions, + PluginContext, + PluginSettingsSection, + UILocation, + Plugin, + PluginLogEntry, +} from './PluginInterfaces.js'; + +export { generateThemeCSS } from './theme-css.js'; + +export { PluginRegistry } from './PluginRegistry.js'; +export type { PluginRegistryOptions } from './PluginRegistry.js'; + +export { createPluginContext } from './PluginContext.js'; +export type { PluginContextOptions } from './PluginContext.js'; diff --git a/src/interfaces.ts b/src/interfaces.ts new file mode 100644 index 0000000..0a0c9a3 --- /dev/null +++ b/src/interfaces.ts @@ -0,0 +1,34 @@ +/** + * Minimal key-value storage adapter. + * Pass `localStorage` in browser environments, or any Map-backed adapter for Node.js / tests. + */ +export interface IPluginStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +/** + * Minimal event emitter interface for the plugin events API. + * Compatible with Node.js EventEmitter, matrix-js-sdk MatrixClient, and similar clients. + */ +export interface IPluginEventClient { + on(event: string, handler: (...args: any[]) => void): void; + off(event: string, handler: (...args: any[]) => void): void; +} + +/** + * In-memory storage implementation — used as the default when no storage adapter is provided. + */ +export class MemoryStorage implements IPluginStorage { + private readonly store = new Map(); + + /** @inheritdoc */ + getItem(key: string): string | null { + return this.store.get(key) ?? null; + } + + /** @inheritdoc */ + setItem(key: string, value: string): void { + this.store.set(key, value); + } +} diff --git a/src/theme-css.ts b/src/theme-css.ts new file mode 100644 index 0000000..962c39f --- /dev/null +++ b/src/theme-css.ts @@ -0,0 +1,97 @@ +import type { PluginTheme } from './PluginInterfaces.js'; + +/** + * Generates a CSS block that overrides the folds vanilla-extract design tokens + * for a given theme class name. + * + * NOTE: The `--oq6d07*` variable names are stable compiled identifiers for the + * version of the `folds` design system used in Paarrot. They will need updating + * if folds is upgraded to a version that recompiles its theme contract. + */ +export function generateThemeCSS(className: string, theme: PluginTheme): string { + const { + background: bg, + surface: sf, + surfaceVariant: sv, + primary: pr, + secondary: sc, + success: su, + warning: wa, + critical: cr, + other: ot, + } = theme.colors; + + const vars = ` + .${className} { + --oq6d070: ${bg.container}; + --oq6d071: ${bg.containerHover}; + --oq6d072: ${bg.containerActive}; + --oq6d073: ${bg.containerLine}; + --oq6d074: ${bg.onContainer}; + --oq6d075: ${sf.container}; + --oq6d076: ${sf.containerHover}; + --oq6d077: ${sf.containerActive}; + --oq6d078: ${sf.containerLine}; + --oq6d079: ${sf.onContainer}; + --oq6d07a: ${sv.container}; + --oq6d07b: ${sv.containerHover}; + --oq6d07c: ${sv.containerActive}; + --oq6d07d: ${sv.containerLine}; + --oq6d07e: ${sv.onContainer}; + --oq6d07f: ${pr.main}; + --oq6d07g: ${pr.mainHover}; + --oq6d07h: ${pr.mainActive}; + --oq6d07i: ${pr.mainLine}; + --oq6d07j: ${pr.onMain}; + --oq6d07k: ${pr.container}; + --oq6d07l: ${pr.containerHover}; + --oq6d07m: ${pr.containerActive}; + --oq6d07n: ${pr.containerLine}; + --oq6d07o: ${pr.onContainer}; + --oq6d07p: ${sc.main}; + --oq6d07q: ${sc.mainHover}; + --oq6d07r: ${sc.mainActive}; + --oq6d07s: ${sc.mainLine}; + --oq6d07t: ${sc.onMain}; + --oq6d07u: ${sc.container}; + --oq6d07v: ${sc.containerHover}; + --oq6d07w: ${sc.containerActive}; + --oq6d07x: ${sc.containerLine}; + --oq6d07y: ${sc.onContainer}; + --oq6d07z: ${su.main}; + --oq6d0710: ${su.mainHover}; + --oq6d0711: ${su.mainActive}; + --oq6d0712: ${su.mainLine}; + --oq6d0713: ${su.onMain}; + --oq6d0714: ${su.container}; + --oq6d0715: ${su.containerHover}; + --oq6d0716: ${su.containerActive}; + --oq6d0717: ${su.containerLine}; + --oq6d0718: ${su.onContainer}; + --oq6d0719: ${wa.main}; + --oq6d071a: ${wa.mainHover}; + --oq6d071b: ${wa.mainActive}; + --oq6d071c: ${wa.mainLine}; + --oq6d071d: ${wa.onMain}; + --oq6d071e: ${wa.container}; + --oq6d071f: ${wa.containerHover}; + --oq6d071g: ${wa.containerActive}; + --oq6d071h: ${wa.containerLine}; + --oq6d071i: ${wa.onContainer}; + --oq6d071j: ${cr.main}; + --oq6d071k: ${cr.mainHover}; + --oq6d071l: ${cr.mainActive}; + --oq6d071m: ${cr.mainLine}; + --oq6d071n: ${cr.onMain}; + --oq6d071o: ${cr.container}; + --oq6d071p: ${cr.containerHover}; + --oq6d071q: ${cr.containerActive}; + --oq6d071r: ${cr.containerLine}; + --oq6d071s: ${cr.onContainer}; + --oq6d071t: ${ot.focusRing}; + --oq6d071u: ${ot.shadow}; + --oq6d071v: ${ot.overlay}; + }`; + + return theme.extraCss ? `${vars}\n${theme.extraCss}` : vars; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..917cb68 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,39 @@ +/** + * Plugin metadata as returned by the marketplace / plugin directory. + */ +export interface PluginMetadata { + id: string; + name: string; + version: string; + description: string; + author: string; + repository: string; + thumbnail: string; + downloadUrl: string; + homepage: string; + tags: string[]; + addedDate: string; +} + +/** + * Plugin directory index response from the marketplace. + */ +export interface PluginIndex { + version: string; + updatedAt: string; + plugins: string[]; +} + +/** + * An installed plugin — extends metadata with runtime state. + */ +export interface InstalledPlugin extends PluginMetadata { + installedDate: string; + enabled: boolean; +} + +/** Tab identifiers for the plugin manager UI. */ +export enum PluginTab { + Marketplace = 'marketplace', + Installed = 'installed', +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f725b5e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}