Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
2912170f5e chore: add VERSION v0.4.8 2025-10-24 11:00:48 +00:00
43 changed files with 3142 additions and 4634 deletions

View File

@@ -27,12 +27,3 @@ AUTH_ENABLED=false
AUTH_SETUP_COMPLETED=false
JWT_SECRET=
DATABASE_URL="file:/opt/ProxmoxVE-Local/data/settings.db"
AUTO_SYNC_ENABLED=false
SYNC_INTERVAL_TYPE=
SYNC_INTERVAL_PREDEFINED=
AUTO_DOWNLOAD_NEW=
AUTO_UPDATE_EXISTING=
NOTIFICATION_ENABLED=
APPRISE_URLS=
LAST_AUTO_SYNC=
SYNC_INTERVAL_CRON=

View File

@@ -1 +1 @@
0.4.9
0.4.8

180
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "pve-scripts-local",
"version": "0.1.0",
"dependencies": {
"@prisma/client": "^6.18.0",
"@prisma/client": "^6.17.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8",
@@ -29,7 +29,7 @@
"cron-validator": "^1.2.0",
"dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.548.0",
"lucide-react": "^0.546.0",
"next": "^15.5.6",
"node-cron": "^3.0.3",
"node-pty": "^1.0.0",
@@ -48,7 +48,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.16",
"@tailwindcss/postcss": "^4.1.15",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
@@ -59,7 +59,7 @@
"@types/node-cron": "^3.0.11",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"@vitejs/plugin-react": "^5.0.2",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.38.0",
@@ -2042,9 +2042,9 @@
"license": "MIT"
},
"node_modules/@prisma/client": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.18.0.tgz",
"integrity": "sha512-jnL2I9gDnPnw4A+4h5SuNn8Gc+1mL1Z79U/3I9eE2gbxJG1oSA+62ByPW4xkeDgwE0fqMzzpAZ7IHxYnLZ4iQA==",
"version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.17.1.tgz",
"integrity": "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@@ -2660,9 +2660,9 @@
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.43",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz",
"integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==",
"version": "1.0.0-beta.38",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz",
"integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==",
"dev": true,
"license": "MIT"
},
@@ -3053,49 +3053,56 @@
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
"integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.15.tgz",
"integrity": "sha512-HF4+7QxATZWY3Jr8OlZrBSXmwT3Watj0OogeDvdUY/ByXJHQ+LBtqA2brDb3sBxYslIFx6UP94BJ4X6a4L9Bmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3",
"jiti": "^2.6.1",
"jiti": "^2.6.0",
"lightningcss": "1.30.2",
"magic-string": "^0.30.19",
"source-map-js": "^1.2.1",
"tailwindcss": "4.1.16"
"tailwindcss": "4.1.15"
}
},
"node_modules/@tailwindcss/node/node_modules/tailwindcss": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz",
"integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz",
"integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.15.tgz",
"integrity": "sha512-krhX+UOOgnsUuks2SR7hFafXmLQrKxB4YyRTERuCE59JlYL+FawgaAlSkOYmDRJdf1Q+IFNDMl9iRnBW7QBDfQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.16",
"@tailwindcss/oxide-darwin-arm64": "4.1.16",
"@tailwindcss/oxide-darwin-x64": "4.1.16",
"@tailwindcss/oxide-freebsd-x64": "4.1.16",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.16",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.16",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.16",
"@tailwindcss/oxide-linux-x64-musl": "4.1.16",
"@tailwindcss/oxide-wasm32-wasi": "4.1.16",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.16",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.16"
"@tailwindcss/oxide-android-arm64": "4.1.15",
"@tailwindcss/oxide-darwin-arm64": "4.1.15",
"@tailwindcss/oxide-darwin-x64": "4.1.15",
"@tailwindcss/oxide-freebsd-x64": "4.1.15",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.15",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.15",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.15",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.15",
"@tailwindcss/oxide-linux-x64-musl": "4.1.15",
"@tailwindcss/oxide-wasm32-wasi": "4.1.15",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.15",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.15"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz",
"integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.15.tgz",
"integrity": "sha512-TkUkUgAw8At4cBjCeVCRMc/guVLKOU1D+sBPrHt5uVcGhlbVKxrCaCW9OKUIBv1oWkjh4GbunD/u/Mf0ql6kEA==",
"cpu": [
"arm64"
],
@@ -3110,9 +3117,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz",
"integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.15.tgz",
"integrity": "sha512-xt5XEJpn2piMSfvd1UFN6jrWXyaKCwikP4Pidcf+yfHTSzSpYhG3dcMktjNkQO3JiLCp+0bG0HoWGvz97K162w==",
"cpu": [
"arm64"
],
@@ -3127,9 +3134,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz",
"integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.15.tgz",
"integrity": "sha512-TnWaxP6Bx2CojZEXAV2M01Yl13nYPpp0EtGpUrY+LMciKfIXiLL2r/SiSRpagE5Fp2gX+rflp/Os1VJDAyqymg==",
"cpu": [
"x64"
],
@@ -3144,9 +3151,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz",
"integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.15.tgz",
"integrity": "sha512-quISQDWqiB6Cqhjc3iWptXVZHNVENsWoI77L1qgGEHNIdLDLFnw3/AfY7DidAiiCIkGX/MjIdB3bbBZR/G2aJg==",
"cpu": [
"x64"
],
@@ -3161,9 +3168,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz",
"integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.15.tgz",
"integrity": "sha512-ObG76+vPlab65xzVUQbExmDU9FIeYLQ5k2LrQdR2Ud6hboR+ZobXpDoKEYXf/uOezOfIYmy2Ta3w0ejkTg9yxg==",
"cpu": [
"arm"
],
@@ -3178,9 +3185,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz",
"integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.15.tgz",
"integrity": "sha512-4WbBacRmk43pkb8/xts3wnOZMDKsPFyEH/oisCm2q3aLZND25ufvJKcDUpAu0cS+CBOL05dYa8D4U5OWECuH/Q==",
"cpu": [
"arm64"
],
@@ -3195,9 +3202,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz",
"integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.15.tgz",
"integrity": "sha512-AbvmEiteEj1nf42nE8skdHv73NoR+EwXVSgPY6l39X12Ex8pzOwwfi3Kc8GAmjsnsaDEbk+aj9NyL3UeyHcTLg==",
"cpu": [
"arm64"
],
@@ -3212,9 +3219,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz",
"integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.15.tgz",
"integrity": "sha512-+rzMVlvVgrXtFiS+ES78yWgKqpThgV19ISKD58Ck+YO5pO5KjyxLt7AWKsWMbY0R9yBDC82w6QVGz837AKQcHg==",
"cpu": [
"x64"
],
@@ -3229,9 +3236,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz",
"integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.15.tgz",
"integrity": "sha512-fPdEy7a8eQN9qOIK3Em9D3TO1z41JScJn8yxl/76mp4sAXFDfV4YXxsiptJcOwy6bGR+70ZSwFIZhTXzQeqwQg==",
"cpu": [
"x64"
],
@@ -3246,9 +3253,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz",
"integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.15.tgz",
"integrity": "sha512-sJ4yd6iXXdlgIMfIBXuVGp/NvmviEoMVWMOAGxtxhzLPp9LOj5k0pMEMZdjeMCl4C6Up+RM8T3Zgk+BMQ0bGcQ==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -3336,9 +3343,9 @@
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz",
"integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.15.tgz",
"integrity": "sha512-sJGE5faXnNQ1iXeqmRin7Ds/ru2fgCiaQZQQz3ZGIDtvbkeV85rAZ0QJFMDg0FrqsffZG96H1U9AQlNBRLsHVg==",
"cpu": [
"arm64"
],
@@ -3353,9 +3360,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz",
"integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.15.tgz",
"integrity": "sha512-NLeHE7jUV6HcFKS504bpOohyi01zPXi2PXmjFfkzTph8xRxDdxkRsXm/xDO5uV5K3brrE1cCwbUYmFUSHR3u1w==",
"cpu": [
"x64"
],
@@ -3370,19 +3377,26 @@
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.16.tgz",
"integrity": "sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.15.tgz",
"integrity": "sha512-IZh8IT76KujRz6d15wZw4eoeViT4TqmzVWNNfpuNCTKiaZUwgr5vtPqO4HjuYDyx3MgGR5qgPt1HMzTeLJyA3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.1.16",
"@tailwindcss/oxide": "4.1.16",
"@tailwindcss/node": "4.1.15",
"@tailwindcss/oxide": "4.1.15",
"postcss": "^8.4.41",
"tailwindcss": "4.1.16"
"tailwindcss": "4.1.15"
}
},
"node_modules/@tailwindcss/postcss/node_modules/tailwindcss": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz",
"integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
@@ -4370,18 +4384,18 @@
]
},
"node_modules/@vitejs/plugin-react": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz",
"integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==",
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz",
"integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.28.4",
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@rolldown/pluginutils": "1.0.0-beta.43",
"@rolldown/pluginutils": "1.0.0-beta.38",
"@types/babel__core": "^7.20.5",
"react-refresh": "^0.18.0"
"react-refresh": "^0.17.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -8661,9 +8675,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.548.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.548.0.tgz",
"integrity": "sha512-63b16z63jM9yc1MwxajHeuu0FRZFsDtljtDjYm26Kd86UQ5HQzu9ksEtoUUw4RBuewodw/tGFmvipePvRsKeDA==",
"version": "0.546.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.546.0.tgz",
"integrity": "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -10631,9 +10645,9 @@
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
"integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -22,7 +22,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@prisma/client": "^6.18.0",
"@prisma/client": "^6.17.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8",
@@ -43,7 +43,7 @@
"cron-validator": "^1.2.0",
"dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.548.0",
"lucide-react": "^0.546.0",
"next": "^15.5.6",
"node-cron": "^3.0.3",
"node-pty": "^1.0.0",
@@ -62,7 +62,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.16",
"@tailwindcss/postcss": "^4.1.15",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
@@ -73,7 +73,7 @@
"@types/node-cron": "^3.0.11",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"@vitejs/plugin-react": "^5.0.2",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.38.0",

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
"documentation": "https://docs.bunkerweb.io/latest/",
"website": "https://www.bunkerweb.io/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/bunkerweb.webp",
"config_path": "/etc/bunkerweb/variables.env",
"config_path": "/opt/bunkerweb/variables.env",
"description": "BunkerWeb is a security-focused web server that enhances web application protection. It guards against common web vulnerabilities like SQL injection, XSS, and CSRF. It features simple setup and configuration using a YAML file, customizable security rules, and provides detailed logs for traffic monitoring and threat detection.",
"install_methods": [
{

View File

@@ -1,48 +0,0 @@
{
"name": "PVE LXC Execute Command",
"slug": "lxc-execute",
"categories": [
1
],
"date_created": "2025-09-18",
"type": "pve",
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,
"website": null,
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/proxmox.webp",
"config_path": "",
"description": "This script allows administrators to execute a custom command inside one or multiple LXC containers on a Proxmox VE node. Containers can be selectively excluded via an interactive checklist. If a container is stopped, the script will automatically start it, run the command, and then shut it down again. Only Debian and Ubuntu based containers are supported.",
"install_methods": [
{
"type": "default",
"script": "tools/pve/execute.sh",
"resources": {
"cpu": null,
"ram": null,
"hdd": null,
"os": null,
"version": null
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Execute within the Proxmox shell.",
"type": "info"
},
{
"text": "Non-Debian/Ubuntu containers will be skipped automatically.",
"type": "info"
},
{
"text": "Stopped containers will be started temporarily to run the command, then shut down again.",
"type": "warning"
}
]
}

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 10,
"os": "debian",
"version": "13"
"version": "12"
}
}
],

View File

@@ -12,7 +12,7 @@
"documentation": "https://github.com/HydroshieldMKII/Guardian/blob/main/README.md",
"config_path": "/opt/guardian/.env",
"website": "https://github.com/HydroshieldMKII/Guardian",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/guardian-plex.webp",
"logo": null,
"description": "Guardian is a lightweight companion app for Plex that lets you monitor, approve or block devices in real time. It helps you enforce per-user or global policies, stop unwanted sessions automatically and grant temporary access - all through a simple web interface.",
"install_methods": [
{

View File

@@ -21,7 +21,7 @@
"resources": {
"cpu": 2,
"ram": 2048,
"hdd": 16,
"hdd": 8,
"os": "ubuntu",
"version": "24.04"
}

View File

@@ -1,40 +0,0 @@
{
"name": "jotty",
"slug": "jotty",
"categories": [
12
],
"date_created": "2025-10-21",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 3000,
"documentation": "https://github.com/fccview/jotty/blob/main/README.md",
"website": "https://github.com/fccview/jotty",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/jotty.webp",
"config_path": "/opt/jotty/.env",
"description": "A simple, self-hosted app for your checklists and notes. Tired of bloated, cloud-based to-do apps? jotty is a lightweight alternative for managing your personal checklists and notes. It's built with Next.js 14, is easy to deploy, and keeps all your data on your own server.",
"install_methods": [
{
"type": "default",
"script": "ct/jotty.sh",
"resources": {
"cpu": 2,
"ram": 3072,
"hdd": 6,
"os": "debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "jotty was previously named rwMarkable",
"type": "info"
}
]
}

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 8,
"os": "debian",
"version": "13"
"version": "12"
}
}
],

View File

@@ -1,186 +1,186 @@
{
"categories": [
{
"name": "Proxmox & Virtualization",
"id": 1,
"sort_order": 1.0,
"description": "Tools and scripts to manage Proxmox VE and virtualization platforms effectively.",
"icon": "server"
},
{
"name": "Operating Systems",
"id": 2,
"sort_order": 2.0,
"description": "Scripts for deploying and managing various operating systems.",
"icon": "monitor"
},
{
"name": "Containers & Docker",
"id": 3,
"sort_order": 3.0,
"description": "Solutions for containerization using Docker and related technologies.",
"icon": "box"
},
{
"name": "Network & Firewall",
"id": 4,
"sort_order": 4.0,
"description": "Enhance network security and configure firewalls with ease.",
"icon": "shield"
},
{
"name": "Adblock & DNS",
"id": 5,
"sort_order": 5.0,
"description": "Optimize your network with DNS and ad-blocking solutions.",
"icon": "ban"
},
{
"name": "Authentication & Security",
"id": 6,
"sort_order": 6.0,
"description": "Secure your infrastructure with authentication and security tools.",
"icon": "lock"
},
{
"name": "Backup & Recovery",
"id": 7,
"sort_order": 7.0,
"description": "Reliable backup and recovery scripts to protect your data.",
"icon": "archive"
},
{
"name": "Databases",
"id": 8,
"sort_order": 8.0,
"description": "Deploy and manage robust database systems with ease.",
"icon": "database"
},
{
"name": "Monitoring & Analytics",
"id": 9,
"sort_order": 9.0,
"description": "Monitor system performance and analyze data seamlessly.",
"icon": "bar-chart"
},
{
"name": "Dashboards & Frontends",
"id": 10,
"sort_order": 10.0,
"description": "Create interactive dashboards and user-friendly frontends.",
"icon": "layout"
},
{
"name": "Files & Downloads",
"id": 11,
"sort_order": 11.0,
"description": "Manage file sharing and downloading solutions efficiently.",
"icon": "download"
},
{
"name": "Documents & Notes",
"id": 12,
"sort_order": 12.0,
"description": "Organize and manage documents and note-taking tools.",
"icon": "file-text"
},
{
"name": "Media & Streaming",
"id": 13,
"sort_order": 13.0,
"description": "Stream and manage media effortlessly across devices.",
"icon": "play"
},
{
"name": "*Arr Suite",
"id": 14,
"sort_order": 14.0,
"description": "Automated media management with the popular *Arr suite tools.",
"icon": "tv"
},
{
"name": "NVR & Cameras",
"id": 15,
"sort_order": 15.0,
"description": "Manage network video recorders and camera setups.",
"icon": "camera"
},
{
"name": "IoT & Smart Home",
"id": 16,
"sort_order": 16.0,
"description": "Control and automate IoT devices and smart home systems.",
"icon": "home"
},
{
"name": "ZigBee, Z-Wave & Matter",
"id": 17,
"sort_order": 17.0,
"description": "Solutions for ZigBee, Z-Wave, and Matter-based device management.",
"icon": "radio"
},
{
"name": "MQTT & Messaging",
"id": 18,
"sort_order": 18.0,
"description": "Set up reliable messaging and MQTT-based communication systems.",
"icon": "message-circle"
},
{
"name": "Automation & Scheduling",
"id": 19,
"sort_order": 19.0,
"description": "Automate tasks and manage scheduling with powerful tools.",
"icon": "clock"
},
{
"name": "AI / Coding & Dev-Tools",
"id": 20,
"sort_order": 20.0,
"description": "Leverage AI and developer tools for smarter coding workflows.",
"icon": "code"
},
{
"name": "Webservers & Proxies",
"id": 21,
"sort_order": 21.0,
"description": "Deploy and configure web servers and proxy solutions.",
"icon": "globe"
},
{
"name": "Bots & ChatOps",
"id": 22,
"sort_order": 22.0,
"description": "Enhance collaboration with bots and ChatOps integrations.",
"icon": "bot"
},
{
"name": "Finance & Budgeting",
"id": 23,
"sort_order": 23.0,
"description": "Track expenses and manage budgets efficiently.",
"icon": "dollar-sign"
},
{
"name": "Gaming & Leisure",
"id": 24,
"sort_order": 24.0,
"description": "Scripts for gaming servers and leisure-related tools.",
"icon": "gamepad-2"
},
{
"name": "Business & ERP",
"id": 25,
"sort_order": 25.0,
"description": "Streamline business operations with ERP and management tools.",
"icon": "building"
},
{
"name": "Miscellaneous",
"id": 0,
"sort_order": 99.0,
"description": "General scripts and tools that don't fit into other categories.",
"icon": "more-horizontal"
}
]
}
"categories": [
{
"name": "Proxmox & Virtualization",
"id": 1,
"sort_order": 1.0,
"description": "Tools and scripts to manage Proxmox VE and virtualization platforms effectively.",
"icon": "server"
},
{
"name": "Operating Systems",
"id": 2,
"sort_order": 2.0,
"description": "Scripts for deploying and managing various operating systems.",
"icon": "monitor"
},
{
"name": "Containers & Docker",
"id": 3,
"sort_order": 3.0,
"description": "Solutions for containerization using Docker and related technologies.",
"icon": "box"
},
{
"name": "Network & Firewall",
"id": 4,
"sort_order": 4.0,
"description": "Enhance network security and configure firewalls with ease.",
"icon": "shield"
},
{
"name": "Adblock & DNS",
"id": 5,
"sort_order": 5.0,
"description": "Optimize your network with DNS and ad-blocking solutions.",
"icon": "ban"
},
{
"name": "Authentication & Security",
"id": 6,
"sort_order": 6.0,
"description": "Secure your infrastructure with authentication and security tools.",
"icon": "lock"
},
{
"name": "Backup & Recovery",
"id": 7,
"sort_order": 7.0,
"description": "Reliable backup and recovery scripts to protect your data.",
"icon": "archive"
},
{
"name": "Databases",
"id": 8,
"sort_order": 8.0,
"description": "Deploy and manage robust database systems with ease.",
"icon": "database"
},
{
"name": "Monitoring & Analytics",
"id": 9,
"sort_order": 9.0,
"description": "Monitor system performance and analyze data seamlessly.",
"icon": "bar-chart"
},
{
"name": "Dashboards & Frontends",
"id": 10,
"sort_order": 10.0,
"description": "Create interactive dashboards and user-friendly frontends.",
"icon": "layout"
},
{
"name": "Files & Downloads",
"id": 11,
"sort_order": 11.0,
"description": "Manage file sharing and downloading solutions efficiently.",
"icon": "download"
},
{
"name": "Documents & Notes",
"id": 12,
"sort_order": 12.0,
"description": "Organize and manage documents and note-taking tools.",
"icon": "file-text"
},
{
"name": "Media & Streaming",
"id": 13,
"sort_order": 13.0,
"description": "Stream and manage media effortlessly across devices.",
"icon": "play"
},
{
"name": "*Arr Suite",
"id": 14,
"sort_order": 14.0,
"description": "Automated media management with the popular *Arr suite tools.",
"icon": "tv"
},
{
"name": "NVR & Cameras",
"id": 15,
"sort_order": 15.0,
"description": "Manage network video recorders and camera setups.",
"icon": "camera"
},
{
"name": "IoT & Smart Home",
"id": 16,
"sort_order": 16.0,
"description": "Control and automate IoT devices and smart home systems.",
"icon": "home"
},
{
"name": "ZigBee, Z-Wave & Matter",
"id": 17,
"sort_order": 17.0,
"description": "Solutions for ZigBee, Z-Wave, and Matter-based device management.",
"icon": "radio"
},
{
"name": "MQTT & Messaging",
"id": 18,
"sort_order": 18.0,
"description": "Set up reliable messaging and MQTT-based communication systems.",
"icon": "message-circle"
},
{
"name": "Automation & Scheduling",
"id": 19,
"sort_order": 19.0,
"description": "Automate tasks and manage scheduling with powerful tools.",
"icon": "clock"
},
{
"name": "AI / Coding & Dev-Tools",
"id": 20,
"sort_order": 20.0,
"description": "Leverage AI and developer tools for smarter coding workflows.",
"icon": "code"
},
{
"name": "Webservers & Proxies",
"id": 21,
"sort_order": 21.0,
"description": "Deploy and configure web servers and proxy solutions.",
"icon": "globe"
},
{
"name": "Bots & ChatOps",
"id": 22,
"sort_order": 22.0,
"description": "Enhance collaboration with bots and ChatOps integrations.",
"icon": "bot"
},
{
"name": "Finance & Budgeting",
"id": 23,
"sort_order": 23.0,
"description": "Track expenses and manage budgets efficiently.",
"icon": "dollar-sign"
},
{
"name": "Gaming & Leisure",
"id": 24,
"sort_order": 24.0,
"description": "Scripts for gaming servers and leisure-related tools.",
"icon": "gamepad-2"
},
{
"name": "Business & ERP",
"id": 25,
"sort_order": 25.0,
"description": "Streamline business operations with ERP and management tools.",
"icon": "building"
},
{
"name": "Miscellaneous",
"id": 0,
"sort_order": 99.0,
"description": "General scripts and tools that don't fit into other categories.",
"icon": "more-horizontal"
}
]
}

View File

@@ -23,7 +23,7 @@
"ram": 1024,
"hdd": 4,
"os": "debian",
"version": "12"
"version": "13"
}
}
],

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 8,
"os": "debian",
"version": "12"
"version": "13"
}
},
{

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 6,
"os": "debian",
"version": "12"
"version": "13"
}
}
],

View File

@@ -23,7 +23,7 @@
"ram": 1024,
"hdd": 4,
"os": "debian",
"version": "12"
"version": "13"
}
}
],

View File

@@ -1,40 +0,0 @@
{
"name": "Open-Archiver",
"slug": "open-archiver",
"categories": [
7
],
"date_created": "2025-10-18",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 3000,
"documentation": "https://docs.openarchiver.com/",
"config_path": "/opt/openarchiver/.env",
"website": "https://openarchiver.com/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/open-archiver.webp",
"description": "Open Archiver is a secure, self-hosted email archiving solution, and it's completely open source. Get an email archiver that enables full-text search across email and attachments. Create a permanent, searchable, and compliant mail archive from Google Workspace, Microsoft 35, and any IMAP server.",
"install_methods": [
{
"type": "default",
"script": "ct/open-archiver.sh",
"resources": {
"cpu": 2,
"ram": 3072,
"hdd": 8,
"os": "debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Data directory is: `/opt/openarchiver-data`. If you have a lot of email, you might consider mounting external storage to this directory.",
"type": "info"
}
]
}

View File

@@ -23,7 +23,7 @@
"ram": 8192,
"hdd": 25,
"os": "debian",
"version": "12"
"version": "13"
}
}
],

View File

@@ -19,8 +19,8 @@
"type": "default",
"script": "ct/paperless-ai.sh",
"resources": {
"cpu": 4,
"ram": 4096,
"cpu": 2,
"ram": 2048,
"hdd": 20,
"os": "debian",
"version": "13"

View File

@@ -23,7 +23,7 @@
"ram": 1024,
"hdd": 4,
"os": "debian",
"version": "12"
"version": "13"
}
}
],

View File

@@ -23,7 +23,7 @@
"ram": 512,
"hdd": 2,
"os": "debian",
"version": "12"
"version": "13"
}
}
],

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 5,
"os": "Debian",
"version": "13"
"version": "12"
}
}
],

View File

@@ -356,7 +356,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
}
}, [selectedCategory]);
const handleCardClick = (scriptCard: ScriptCardType) => {
const handleCardClick = (scriptCard: { slug: string }) => {
// All scripts are GitHub scripts, open modal
setSelectedSlug(scriptCard.slug);
setIsModalOpen(true);

View File

@@ -41,7 +41,6 @@ export function FilterBar({
}: FilterBarProps) {
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const updateFilters = (updates: Partial<FilterState>) => {
onFiltersChange({ ...filters, ...updates });
@@ -99,17 +98,44 @@ export function FilterBar({
{!isLoadingFilters && (
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
<div className="flex items-center gap-2">
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
</div>
)}
{/* Search Bar */}
<div className="mb-4">
<div className="relative max-w-md w-full">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="h-5 w-5 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<input
type="text"
placeholder="Search scripts..."
value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
/>
{filters.searchQuery && (
<Button
onClick={() => setIsMinimized(!isMinimized)}
onClick={() => updateFilters({ searchQuery: "" })}
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
title={isMinimized ? "Expand filters" : "Minimize filters"}
className="absolute inset-y-0 right-0 pr-3 text-muted-foreground hover:text-foreground"
>
<svg
className={`h-4 w-4 transition-transform ${isMinimized ? "" : "rotate-180"}`}
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -118,69 +144,16 @@ export function FilterBar({
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 15l7-7 7 7"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
</div>
)}
</div>
)}
</div>
{/* Filter Content - Conditionally rendered based on minimized state */}
{!isMinimized && !isLoadingFilters && (
<>
{/* Search Bar */}
<div className="mb-4">
<div className="relative max-w-md w-full">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="h-5 w-5 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<input
type="text"
placeholder="Search scripts..."
value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
/>
{filters.searchQuery && (
<Button
onClick={() => updateFilters({ searchQuery: "" })}
variant="ghost"
size="icon"
className="absolute inset-y-0 right-0 pr-3 text-muted-foreground hover:text-foreground"
>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
)}
</div>
</div>
{/* Filter Buttons */}
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
{/* Filter Buttons */}
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
{/* Updateable Filter */}
<Button
onClick={() => {
@@ -458,8 +431,6 @@ export function FilterBar({
</Button>
)}
</div>
</>
)}
{/* Click outside to close dropdowns */}
{(isTypeDropdownOpen || isSortDropdownOpen) && (

View File

@@ -46,8 +46,6 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
const [appriseUrls, setAppriseUrls] = useState<string[]>([]);
const [appriseUrlsText, setAppriseUrlsText] = useState('');
const [lastAutoSync, setLastAutoSync] = useState('');
const [lastAutoSyncError, setLastAutoSyncError] = useState<string | null>(null);
const [lastAutoSyncErrorTime, setLastAutoSyncErrorTime] = useState<string | null>(null);
const [cronValidationError, setCronValidationError] = useState('');
// Load existing settings when modal opens
@@ -313,8 +311,6 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
setAppriseUrls(settings.appriseUrls ?? []);
setAppriseUrlsText((settings.appriseUrls ?? []).join('\n'));
setLastAutoSync(settings.lastAutoSync ?? '');
setLastAutoSyncError(settings.lastAutoSyncError ?? null);
setLastAutoSyncErrorTime(settings.lastAutoSyncErrorTime ?? null);
}
}
} catch (error) {
@@ -327,6 +323,30 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
setMessage(null);
try {
// Validate cron expression if custom
if (syncIntervalType === 'custom' && syncIntervalCron) {
const response = await fetch('/api/settings/auto-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
autoSyncEnabled,
syncIntervalType,
syncIntervalPredefined,
syncIntervalCron,
autoDownloadNew,
autoUpdateExisting,
notificationEnabled,
appriseUrls: appriseUrls
})
});
if (!response.ok) {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save auto-sync settings' });
return;
}
}
const response = await fetch('/api/settings/auto-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -341,7 +361,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
appriseUrls: appriseUrls
})
});
if (response.ok) {
setMessage({ type: 'success', text: 'Auto-sync settings saved successfully!' });
setTimeout(() => setMessage(null), 3000);
@@ -804,41 +824,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</div>
<Toggle
checked={autoSyncEnabled}
onCheckedChange={async (checked) => {
setAutoSyncEnabled(checked);
// Auto-save when toggle changes
try {
// If syncIntervalType is custom but no cron expression, fallback to predefined
const effectiveSyncIntervalType = (syncIntervalType === 'custom' && !syncIntervalCron)
? 'predefined'
: syncIntervalType;
const response = await fetch('/api/settings/auto-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
autoSyncEnabled: checked,
syncIntervalType: effectiveSyncIntervalType,
syncIntervalPredefined: effectiveSyncIntervalType === 'predefined' ? syncIntervalPredefined : undefined,
syncIntervalCron: effectiveSyncIntervalType === 'custom' ? syncIntervalCron : undefined,
autoDownloadNew,
autoUpdateExisting,
notificationEnabled,
appriseUrls: appriseUrls
})
});
if (response.ok) {
// Update local state to reflect the effective sync interval type
if (effectiveSyncIntervalType !== syncIntervalType) {
setSyncIntervalType(effectiveSyncIntervalType);
}
}
} catch (error) {
console.error('Error saving auto-sync toggle:', error);
}
}}
onCheckedChange={setAutoSyncEnabled}
disabled={isSaving}
/>
</div>
@@ -1030,25 +1016,6 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</div>
)}
{lastAutoSyncError && (
<div className="p-3 bg-error/10 text-error-foreground border border-error/20 rounded-md">
<div className="flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<div>
<p className="text-sm font-medium">Last sync error:</p>
<p className="text-sm mt-1">{lastAutoSyncError}</p>
{lastAutoSyncErrorTime && (
<p className="text-xs mt-1 opacity-75">
{new Date(lastAutoSyncErrorTime).toLocaleString()}
</p>
)}
</div>
</div>
</div>
)}
<div className="flex gap-2">
<Button
onClick={triggerManualSync}

View File

@@ -935,18 +935,6 @@ export function InstalledScriptsTab() {
>
{showAutoDetectForm ? 'Cancel Auto-Detect' : '🔍 Auto-Detect LXC Containers (Must contain a tag with "community-script")'}
</Button>
<Button
onClick={() => {
cleanupRunRef.current = false; // Allow cleanup to run again
void cleanupMutation.mutate();
}}
disabled={cleanupMutation.isPending}
variant="outline"
size="default"
className="border-warning/30 text-warning hover:bg-warning/10"
>
{cleanupMutation.isPending ? '🧹 Cleaning up...' : '🧹 Cleanup Orphaned Scripts'}
</Button>
<Button
onClick={() => {
// Trigger status check by calling the mutation directly

View File

@@ -165,20 +165,6 @@ export function ScriptDetailModal({
{script.privileged && <PrivilegedBadge />}
</div>
</div>
{/* Interface Port*/}
{script.interface_port && (
<div className="ml-3 sm:ml-4 flex-shrink-0">
<div className="bg-primary/10 border border-primary/30 rounded-lg px-3 py-1.5 sm:px-4 sm:py-2">
<span className="text-xs sm:text-sm font-medium text-muted-foreground mr-2">
Port:
</span>
<span className="text-sm sm:text-base font-semibold text-foreground font-mono">
{script.interface_port}
</span>
</div>
</div>
)}
</div>
{/* Close Button */}

View File

@@ -34,7 +34,6 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
});
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
const [isNewestMinimized, setIsNewestMinimized] = useState(false);
const gridRef = useRef<HTMLDivElement>(null);
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
@@ -536,30 +535,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
};
const handleDownloadAllFiltered = () => {
let scriptsToDownload: ScriptCardType[] = filteredScripts;
if (!hasActiveFilters) {
const scriptMap = new Map<string, ScriptCardType>();
filteredScripts.forEach(script => {
if (script?.slug) {
scriptMap.set(script.slug, script);
}
});
newestScripts.forEach(script => {
if (script?.slug && !scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script);
}
});
scriptsToDownload = Array.from(scriptMap.values());
}
const slugsToDownload = scriptsToDownload.map(script => script.slug).filter(Boolean);
const slugsToDownload = filteredScripts.map(script => script.slug).filter(Boolean);
if (slugsToDownload.length > 0) {
void downloadScriptsIndividually(slugsToDownload);
}
@@ -598,7 +574,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
}, []);
const handleCardClick = (scriptCard: ScriptCardType) => {
const handleCardClick = (scriptCard: { slug: string }) => {
// All scripts are GitHub scripts, open modal
setSelectedSlug(scriptCard.slug);
setIsModalOpen(true);
@@ -690,8 +666,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
onViewModeChange={setViewMode}
/>
{/* Newest Scripts Carousel - Only show when no search, filters, or category is active */}
{newestScripts.length > 0 && !hasActiveFilters && !selectedCategory && (
{/* Newest Scripts Carousel - Always show when there are newest scripts */}
{newestScripts.length > 0 && (
<div className="mb-8">
<div className="bg-card border-l-4 border-l-primary border border-border rounded-lg p-6 shadow-lg">
<div className="flex items-center justify-between mb-4">
@@ -699,64 +675,39 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
<Clock className="h-6 w-6 text-primary" />
Newest Scripts
</h2>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{newestScripts.length} recently added
</span>
<Button
onClick={() => setIsNewestMinimized(!isNewestMinimized)}
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
title={isNewestMinimized ? "Expand newest scripts" : "Minimize newest scripts"}
>
<svg
className={`h-4 w-4 transition-transform ${isNewestMinimized ? "" : "rotate-180"}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 15l7-7 7 7"
/>
</svg>
</Button>
</div>
<span className="text-sm text-muted-foreground">
{newestScripts.length} recently added
</span>
</div>
{!isNewestMinimized && (
<div className="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent">
<div className="flex gap-4 pb-2" style={{ minWidth: 'max-content' }}>
{newestScripts.map((script, index) => {
if (!script || typeof script !== 'object') {
return null;
}
const uniqueKey = `newest-${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<div key={uniqueKey} className="flex-shrink-0 w-64 sm:w-72 md:w-80">
<div className="relative">
<ScriptCard
script={script}
onClick={handleCardClick}
isSelected={selectedSlugs.has(script.slug ?? '')}
onToggleSelect={toggleScriptSelection}
/>
{/* NEW badge */}
<div className="absolute top-2 right-2 bg-success text-success-foreground text-xs font-semibold px-2 py-1 rounded-md shadow-md z-10">
NEW
</div>
<div className="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent">
<div className="flex gap-4 pb-2" style={{ minWidth: 'max-content' }}>
{newestScripts.map((script, index) => {
if (!script || typeof script !== 'object') {
return null;
}
const uniqueKey = `newest-${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<div key={uniqueKey} className="flex-shrink-0 w-64 sm:w-72 md:w-80">
<div className="relative">
<ScriptCard
script={script}
onClick={handleCardClick}
isSelected={selectedSlugs.has(script.slug ?? '')}
onToggleSelect={toggleScriptSelection}
/>
{/* NEW badge */}
<div className="absolute top-2 right-2 bg-success text-success-foreground text-xs font-semibold px-2 py-1 rounded-md shadow-md z-10">
NEW
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
)}

View File

@@ -64,12 +64,14 @@ export async function POST(request: NextRequest) {
// Validate custom cron expression
if (settings.syncIntervalType === 'custom') {
if (!settings.syncIntervalCron || typeof settings.syncIntervalCron !== 'string' || settings.syncIntervalCron.trim() === '') {
// Fallback to predefined if custom is selected but no cron expression
settings.syncIntervalType = 'predefined';
settings.syncIntervalPredefined = settings.syncIntervalPredefined || '1hour';
settings.syncIntervalCron = '';
} else if (!isValidCron(settings.syncIntervalCron, { seconds: false })) {
if (!settings.syncIntervalCron || typeof settings.syncIntervalCron !== 'string') {
return NextResponse.json(
{ error: 'Custom cron expression is required when syncIntervalType is "custom"' },
{ status: 400 }
);
}
if (!isValidCron(settings.syncIntervalCron, { seconds: false })) {
return NextResponse.json(
{ error: 'Invalid cron expression' },
{ status: 400 }
@@ -136,9 +138,7 @@ export async function POST(request: NextRequest) {
'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting ? 'true' : 'false',
'NOTIFICATION_ENABLED': settings.notificationEnabled ? 'true' : 'false',
'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (settings.appriseUrls || '[]'),
'LAST_AUTO_SYNC': settings.lastAutoSync || '',
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError || '',
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime || ''
'LAST_AUTO_SYNC': settings.lastAutoSync || ''
};
// Update or add each setting
@@ -160,28 +160,15 @@ export async function POST(request: NextRequest) {
// Reschedule auto-sync service with new settings
try {
const { getAutoSyncService, setAutoSyncService } = await import('../../../../server/lib/autoSyncInit.js');
let autoSyncService = getAutoSyncService();
// If no global instance exists, create one
if (!autoSyncService) {
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
autoSyncService = new AutoSyncService();
setAutoSyncService(autoSyncService);
}
// Update the global service instance with new settings
autoSyncService.saveSettings(settings);
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
const autoSyncService = new AutoSyncService();
if (settings.autoSyncEnabled) {
autoSyncService.scheduleAutoSync();
console.log('Auto-sync rescheduled with new settings');
} else {
autoSyncService.stopAutoSync();
// Ensure the service is completely stopped and won't restart
autoSyncService.isRunning = false;
// Also stop the global service instance if it exists
const { stopAutoSync: stopGlobalAutoSync } = await import('../../../../server/lib/autoSyncInit.js');
stopGlobalAutoSync();
console.log('Auto-sync stopped');
}
} catch (error) {
console.error('Error rescheduling auto-sync service:', error);
@@ -217,9 +204,7 @@ export async function GET() {
autoUpdateExisting: false,
notificationEnabled: false,
appriseUrls: [],
lastAutoSync: '',
lastAutoSyncError: null,
lastAutoSyncErrorTime: null
lastAutoSync: ''
}
});
}
@@ -243,9 +228,7 @@ export async function GET() {
return [];
}
})(),
lastAutoSync: getEnvValue(envContent, 'LAST_AUTO_SYNC') || '',
lastAutoSyncError: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR') || null,
lastAutoSyncErrorTime: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR_TIME') || null
lastAutoSync: getEnvValue(envContent, 'LAST_AUTO_SYNC') || ''
};
return NextResponse.json({ settings });

View File

@@ -887,142 +887,77 @@ export const installedScriptsRouter = createTRPCRouter({
);
// Group scripts by server to batch check containers
const scriptsByServer = new Map<number, any[]>();
for (const script of scriptsToCheck) {
const scriptData = script as any;
if (!scriptData.server_id) continue;
if (!scriptsByServer.has(scriptData.server_id)) {
scriptsByServer.set(scriptData.server_id, []);
}
scriptsByServer.get(scriptData.server_id)!.push(scriptData);
}
// Process each server
for (const [serverId, serverScripts] of scriptsByServer.entries()) {
try {
const server = allServers.find((s: any) => s.id === serverId);
const scriptData = script as any;
const server = allServers.find((s: any) => s.id === scriptData.server_id);
if (!server) {
// Server doesn't exist, delete all scripts for this server
for (const scriptData of serverScripts) {
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
}
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
continue;
}
// Test SSH connection
const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) {
console.warn(`cleanupOrphanedScripts: SSH connection failed for server ${String((server as any).name)}, skipping ${serverScripts.length} scripts`);
continue;
}
// Get all existing containers from pct list (more reliable than checking config files)
const listCommand = 'pct list';
let listOutput = '';
// Check if the container config file still exists
const checkCommand = `test -f "/etc/pve/lxc/${scriptData.container_id}.conf" && echo "exists" || echo "not_found"`;
const existingContainerIds = await new Promise<Set<string>>((resolve, reject) => {
const timeout = setTimeout(() => {
console.warn(`cleanupOrphanedScripts: timeout while getting container list from server ${String((server as any).name)}`);
resolve(new Set()); // Treat timeout as no containers found
}, 20000);
// Await full command completion to avoid early false negatives
const containerExists = await new Promise<boolean>((resolve) => {
let combinedOutput = '';
let resolved = false;
const finish = () => {
if (resolved) return;
resolved = true;
const out = combinedOutput.trim();
if (out.includes('exists')) {
resolve(true);
} else if (out.includes('not_found')) {
resolve(false);
} else {
// Unknown output; treat as not found but log for diagnostics
console.warn(`cleanupOrphanedScripts: unexpected output for ${String(scriptData.script_name)} (${String(scriptData.container_id)}): ${out}`);
resolve(false);
}
};
// Add a guard timeout so we don't hang indefinitely
const timer = setTimeout(() => {
console.warn(`cleanupOrphanedScripts: timeout while checking ${String(scriptData.script_name)} on server ${String((server as any).name)}`);
finish();
}, 15000);
void sshExecutionService.executeCommand(
server as Server,
listCommand,
checkCommand,
(data: string) => {
listOutput += data;
combinedOutput += data;
},
(error: string) => {
console.error(`cleanupOrphanedScripts: error getting container list from server ${String((server as any).name)}:`, error);
clearTimeout(timeout);
resolve(new Set()); // Treat error as no containers found
combinedOutput += error;
},
(_exitCode: number) => {
clearTimeout(timeout);
// Parse pct list output to extract container IDs
const containerIds = new Set<string>();
const lines = listOutput.split('\n').filter(line => line.trim());
for (const line of lines) {
// pct list format: CTID Status Name
// Skip header line if present
if (line.includes('CTID') || line.includes('VMID')) continue;
const parts = line.trim().split(/\s+/);
if (parts.length > 0) {
const containerId = parts[0]?.trim();
if (containerId && /^\d{3,4}$/.test(containerId)) {
containerIds.add(containerId);
}
}
}
resolve(containerIds);
clearTimeout(timer);
finish();
}
);
});
// Check each script against the list of existing containers
for (const scriptData of serverScripts) {
try {
const containerId = String(scriptData.container_id).trim();
// Check if container exists in pct list
if (!existingContainerIds.has(containerId)) {
// Also verify config file doesn't exist as a double-check
const checkCommand = `test -f "/etc/pve/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`;
const configExists = await new Promise<boolean>((resolve) => {
let combinedOutput = '';
let resolved = false;
const finish = () => {
if (resolved) return;
resolved = true;
const out = combinedOutput.trim();
resolve(out.includes('exists'));
};
const timer = setTimeout(() => {
finish();
}, 10000);
void sshExecutionService.executeCommand(
server as Server,
checkCommand,
(data: string) => {
combinedOutput += data;
},
(_error: string) => {
// Ignore errors, just check output
},
(_exitCode: number) => {
clearTimeout(timer);
finish();
}
);
});
// If container is not in pct list AND config file doesn't exist, it's orphaned
if (!configExists) {
console.log(`cleanupOrphanedScripts: Removing orphaned script ${String(scriptData.script_name)} (container ${containerId}) from server ${String((server as any).name)}`);
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
} else {
// Config exists but not in pct list - might be in a transitional state, log but don't delete
console.warn(`cleanupOrphanedScripts: Container ${containerId} (${String(scriptData.script_name)}) config exists but not in pct list - may be in transitional state`);
}
}
} catch (error) {
console.error(`cleanupOrphanedScripts: Error checking script ${String((scriptData as any).script_name)}:`, error);
}
if (!containerExists) {
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
} else {
}
} catch (error) {
console.error(`cleanupOrphanedScripts: Error processing server ${serverId}:`, error);
console.error(`Error checking script ${(script as any).script_name}:`, error);
}
}

View File

@@ -115,18 +115,6 @@ export const scriptsRouter = createTRPCRouter({
.input(z.object({ slug: z.string() }))
.query(async ({ input }) => {
try {
console.log('getScriptBySlug called with slug:', input.slug);
console.log('githubJsonService methods:', Object.getOwnPropertyNames(Object.getPrototypeOf(githubJsonService)));
console.log('githubJsonService.getScriptBySlug type:', typeof githubJsonService.getScriptBySlug);
if (typeof githubJsonService.getScriptBySlug !== 'function') {
return {
success: false,
error: 'getScriptBySlug method is not available on githubJsonService',
script: null
};
}
const script = await githubJsonService.getScriptBySlug(input.slug);
if (!script) {
return {
@@ -137,7 +125,6 @@ export const scriptsRouter = createTRPCRouter({
}
return { success: true, script };
} catch (error) {
console.error('Error in getScriptBySlug:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch script',
@@ -503,29 +490,14 @@ export const scriptsRouter = createTRPCRouter({
}))
.mutation(async ({ input }) => {
try {
// Use the global auto-sync service instance
const { getAutoSyncService, setAutoSyncService } = await import('~/server/lib/autoSyncInit');
let autoSyncService = getAutoSyncService();
// If no global instance exists, create one
if (!autoSyncService) {
const { AutoSyncService } = await import('~/server/services/autoSyncService');
autoSyncService = new AutoSyncService();
setAutoSyncService(autoSyncService);
}
// Save settings to both .env file and service instance
const autoSyncService = new AutoSyncService();
autoSyncService.saveSettings(input);
// Reschedule auto-sync if enabled
if (input.autoSyncEnabled) {
autoSyncService.scheduleAutoSync();
console.log('Auto-sync rescheduled with new settings');
} else {
autoSyncService.stopAutoSync();
// Ensure the service is completely stopped and won't restart
autoSyncService.isRunning = false;
console.log('Auto-sync stopped');
}
return { success: true, message: 'Auto-sync settings saved successfully' };

View File

@@ -1,31 +1,21 @@
import { AutoSyncService } from '../services/autoSyncService.js';
let autoSyncService = null;
let isInitialized = false;
/**
* Initialize auto-sync service and schedule cron job if enabled
*/
export function initializeAutoSync() {
if (isInitialized) {
console.log('Auto-sync service already initialized, skipping...');
return;
}
try {
console.log('Initializing auto-sync service...');
autoSyncService = new AutoSyncService();
isInitialized = true;
console.log('AutoSyncService instance created');
// Load settings and schedule if enabled
const settings = autoSyncService.loadSettings();
console.log('Settings loaded:', settings);
if (settings.autoSyncEnabled) {
console.log('Auto-sync is enabled, scheduling cron job...');
autoSyncService.scheduleAutoSync();
console.log('Cron job scheduled');
} else {
console.log('Auto-sync is disabled');
}
@@ -33,7 +23,6 @@ export function initializeAutoSync() {
console.log('Auto-sync service initialized successfully');
} catch (error) {
console.error('Failed to initialize auto-sync service:', error);
console.error('Error stack:', error.stack);
}
}
@@ -46,7 +35,6 @@ export function stopAutoSync() {
console.log('Stopping auto-sync service...');
autoSyncService.stopAutoSync();
autoSyncService = null;
isInitialized = false;
console.log('Auto-sync service stopped');
}
} catch (error) {
@@ -61,13 +49,6 @@ export function getAutoSyncService() {
return autoSyncService;
}
/**
* Set the auto-sync service instance (for external management)
*/
export function setAutoSyncService(service) {
autoSyncService = service;
}
/**
* Graceful shutdown handler
*/

View File

@@ -49,13 +49,6 @@ export function getAutoSyncService(): AutoSyncService | null {
return autoSyncService;
}
/**
* Set the auto-sync service instance (for external management)
*/
export function setAutoSyncService(service: AutoSyncService | null): void {
autoSyncService = service;
}
/**
* Graceful shutdown handler
*/

View File

@@ -25,25 +25,6 @@ export class ScriptManager {
// Initialize lazily to avoid accessing env vars during module load
}
/**
* Safely handle file modification time, providing fallback for invalid dates
* @param mtime - The file modification time from fs.stat
* @returns Date - Valid date or current date as fallback
*/
private safeMtime(mtime: Date): Date {
try {
// Check if the date is valid
if (!mtime || isNaN(mtime.getTime())) {
console.warn('Invalid mtime detected, using current time as fallback');
return new Date();
}
return mtime;
} catch (error) {
console.warn('Error processing mtime:', error);
return new Date();
}
}
private initializeConfig() {
if (this.scriptsDir === null) {
// Handle both absolute and relative paths for testing
@@ -82,7 +63,7 @@ export class ScriptManager {
path: filePath,
extension,
size: stats.size,
lastModified: this.safeMtime(stats.mtime),
lastModified: stats.mtime,
executable
});
}
@@ -144,7 +125,7 @@ export class ScriptManager {
path: filePath,
extension,
size: stats.size,
lastModified: this.safeMtime(stats.mtime),
lastModified: stats.mtime,
executable,
logo,
slug
@@ -231,7 +212,7 @@ export class ScriptManager {
path: filePath,
extension,
size: stats.size,
lastModified: this.safeMtime(stats.mtime),
lastModified: stats.mtime,
executable,
logo,
slug

View File

@@ -6,34 +6,12 @@ import { readFile, writeFile, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import cronValidator from 'cron-validator';
// Global lock to prevent multiple autosync instances from running simultaneously
let globalAutoSyncLock = false;
export class AutoSyncService {
constructor() {
this.cronJob = null;
this.isRunning = false;
}
/**
* Safely convert a date to ISO string, handling invalid dates
* @param {Date} date - The date to convert
* @returns {string} - ISO string or fallback timestamp
*/
safeToISOString(date) {
try {
// Check if the date is valid
if (!date || isNaN(date.getTime())) {
console.warn('Invalid date provided to safeToISOString, using current time as fallback');
return new Date().toISOString();
}
return date.toISOString();
} catch (error) {
console.warn('Error converting date to ISO string:', error instanceof Error ? error.message : String(error));
return new Date().toISOString();
}
}
/**
* Load auto-sync settings from .env file
*/
@@ -42,19 +20,6 @@ export class AutoSyncService {
const envPath = join(process.cwd(), '.env');
const envContent = readFileSync(envPath, 'utf8');
/** @type {{
* autoSyncEnabled: boolean;
* syncIntervalType: string;
* syncIntervalPredefined?: string;
* syncIntervalCron?: string;
* autoDownloadNew: boolean;
* autoUpdateExisting: boolean;
* notificationEnabled: boolean;
* appriseUrls?: string[];
* lastAutoSync?: string;
* lastAutoSyncError?: string;
* lastAutoSyncErrorTime?: string;
* }} */
const settings = {
autoSyncEnabled: false,
syncIntervalType: 'predefined',
@@ -64,9 +29,7 @@ export class AutoSyncService {
autoUpdateExisting: false,
notificationEnabled: false,
appriseUrls: [],
lastAutoSync: '',
lastAutoSyncError: '',
lastAutoSyncErrorTime: ''
lastAutoSync: ''
};
const lines = envContent.split('\n');
@@ -111,12 +74,6 @@ export class AutoSyncService {
case 'LAST_AUTO_SYNC':
settings.lastAutoSync = value;
break;
case 'LAST_AUTO_SYNC_ERROR':
settings.lastAutoSyncError = value;
break;
case 'LAST_AUTO_SYNC_ERROR_TIME':
settings.lastAutoSyncErrorTime = value;
break;
}
}
}
@@ -133,9 +90,7 @@ export class AutoSyncService {
autoUpdateExisting: false,
notificationEnabled: false,
appriseUrls: [],
lastAutoSync: '',
lastAutoSyncError: '',
lastAutoSyncErrorTime: ''
lastAutoSync: ''
};
}
}
@@ -152,8 +107,6 @@ export class AutoSyncService {
* @param {boolean} settings.notificationEnabled
* @param {Array<string>} [settings.appriseUrls]
* @param {string} [settings.lastAutoSync]
* @param {string} [settings.lastAutoSyncError]
* @param {string} [settings.lastAutoSyncErrorTime]
*/
saveSettings(settings) {
try {
@@ -177,37 +130,19 @@ export class AutoSyncService {
'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting.toString(),
'NOTIFICATION_ENABLED': settings.notificationEnabled.toString(),
'APPRISE_URLS': JSON.stringify(settings.appriseUrls || []),
'LAST_AUTO_SYNC': settings.lastAutoSync || '',
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError || '',
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime || ''
'LAST_AUTO_SYNC': settings.lastAutoSync || ''
};
const existingKeys = new Set();
for (const line of lines) {
const trimmedLine = line.trim();
// Skip empty lines and comments
if (!trimmedLine || trimmedLine.startsWith('#')) {
newLines.push(line);
continue;
}
const equalIndex = trimmedLine.indexOf('=');
if (equalIndex === -1) {
// Line doesn't contain '=', keep as is
newLines.push(line);
continue;
}
const key = trimmedLine.substring(0, equalIndex).trim();
if (key && key in settingsMap) {
// Replace existing setting
// @ts-ignore - Dynamic property access is safe here
newLines.push(`${key}=${settingsMap[key]}`);
existingKeys.add(key);
} else {
// Keep other settings as is
const [key] = line.split('=');
const trimmedKey = key?.trim();
if (trimmedKey && trimmedKey in settingsMap) {
// @ts-ignore - Dynamic key access is safe here
newLines.push(`${trimmedKey}=${settingsMap[trimmedKey]}`);
existingKeys.add(trimmedKey);
} else if (trimmedKey && !(trimmedKey in settingsMap)) {
newLines.push(line);
}
}
@@ -235,14 +170,6 @@ export class AutoSyncService {
const settings = this.loadSettings();
if (!settings.autoSyncEnabled) {
console.log('Auto-sync is disabled, not scheduling cron job');
this.isRunning = false; // Ensure we're completely stopped
return;
}
// Check if there's already a global autosync running
if (globalAutoSyncLock) {
console.log('Auto-sync is already running globally, not scheduling new cron job');
return;
}
@@ -273,28 +200,8 @@ export class AutoSyncService {
console.log(`Scheduling auto-sync with cron expression: ${cronExpression}`);
this.cronJob = cron.schedule(cronExpression, async () => {
// Check global lock first
if (globalAutoSyncLock) {
console.log('Auto-sync already running globally, skipping cron execution...');
return;
}
if (this.isRunning) {
console.log('Auto-sync already running locally, skipping...');
return;
}
// Double-check that autosync is still enabled before executing
const currentSettings = this.loadSettings();
if (!currentSettings.autoSyncEnabled) {
console.log('Auto-sync has been disabled, stopping and destroying cron job');
this.stopAutoSync();
return;
}
// Additional check: if cronJob is null, it means it was stopped
if (!this.cronJob) {
console.log('Cron job was stopped, skipping execution');
console.log('Auto-sync already running, skipping...');
return;
}
@@ -314,13 +221,8 @@ export class AutoSyncService {
stopAutoSync() {
if (this.cronJob) {
this.cronJob.stop();
this.cronJob.destroy();
this.cronJob = null;
this.isRunning = false;
console.log('Auto-sync cron job stopped and destroyed');
} else {
console.log('No active cron job to stop');
this.isRunning = false; // Ensure isRunning is false even if no cron job
console.log('Auto-sync cron job stopped');
}
}
@@ -328,19 +230,11 @@ export class AutoSyncService {
* Execute auto-sync process
*/
async executeAutoSync() {
// Check global lock first
if (globalAutoSyncLock) {
console.log('Auto-sync already running globally, skipping...');
return { success: false, message: 'Auto-sync already running globally' };
}
if (this.isRunning) {
console.log('Auto-sync already running locally, skipping...');
return { success: false, message: 'Auto-sync already running locally' };
console.log('Auto-sync already running, skipping...');
return { success: false, message: 'Auto-sync already running' };
}
// Set global lock
globalAutoSyncLock = true;
this.isRunning = true;
const startTime = new Date();
@@ -357,120 +251,56 @@ export class AutoSyncService {
const results = {
jsonSync: syncResult,
newScripts: /** @type {any[]} */ ([]),
updatedScripts: /** @type {any[]} */ ([]),
errors: /** @type {string[]} */ ([])
newScripts: [],
updatedScripts: [],
errors: []
};
// Step 2: Auto-download/update scripts if enabled
const settings = this.loadSettings();
if (settings.autoDownloadNew || settings.autoUpdateExisting) {
console.log('Processing synced JSON files for script downloads...');
// Only process scripts for files that were actually synced
// @ts-ignore - syncedFiles exists in the JavaScript version
if (syncResult.syncedFiles && syncResult.syncedFiles.length > 0) {
console.log(`Processing ${syncResult.syncedFiles.length} synced JSON files for script downloads...`);
// @ts-ignore - syncedFiles exists in the JavaScript version
console.log(`Processing ${syncResult.syncedFiles.length} synced JSON files for new scripts...`);
// Get scripts only for the synced files
const localScriptsService = await import('./localScripts.js');
const syncedScripts = [];
// Get all scripts from synced files
// @ts-ignore - syncedFiles exists in the JavaScript version
const allSyncedScripts = await githubJsonService.getScriptsForFiles(syncResult.syncedFiles);
for (const filename of syncResult.syncedFiles) {
try {
// Extract slug from filename (remove .json extension)
const slug = filename.replace('.json', '');
const script = await localScriptsService.localScriptsService.getScriptBySlug(slug);
if (script) {
syncedScripts.push(script);
}
} catch (error) {
console.warn(`Error loading script from ${filename}:`, error);
}
}
console.log(`Found ${syncedScripts.length} scripts from synced JSON files`);
// Initialize script downloader service
// @ts-ignore - initializeConfig is public in the JS version
scriptDownloaderService.initializeConfig();
// Filter to only truly NEW scripts (not previously downloaded)
const newScripts = [];
const existingScripts = [];
for (const script of syncedScripts) {
try {
// Validate script object
if (!script || !script.slug) {
console.warn('Invalid script object found, skipping:', script);
continue;
}
const isDownloaded = await scriptDownloaderService.isScriptDownloaded(script);
if (!isDownloaded) {
newScripts.push(script);
} else {
existingScripts.push(script);
}
} catch (error) {
console.warn(`Error checking script ${script?.slug || 'unknown'}:`, error);
// Treat as new script if we can't check
if (script && script.slug) {
newScripts.push(script);
}
for (const script of allSyncedScripts) {
const isDownloaded = await scriptDownloaderService.isScriptDownloaded(script);
if (!isDownloaded) {
newScripts.push(script);
}
}
console.log(`Found ${newScripts.length} new scripts and ${existingScripts.length} existing scripts from synced files`);
console.log(`Found ${newScripts.length} new scripts out of ${allSyncedScripts.length} total scripts`);
// Download new scripts
if (settings.autoDownloadNew && newScripts.length > 0) {
console.log(`Auto-downloading ${newScripts.length} new scripts...`);
const downloaded = [];
const errors = [];
for (const script of newScripts) {
try {
const result = await scriptDownloaderService.loadScript(script);
if (result.success) {
downloaded.push(script); // Store full script object for category grouping
console.log(`Downloaded script: ${script.name || script.slug}`);
} else {
errors.push(`${script.name || script.slug}: ${result.message}`);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(`${script.name || script.slug}: ${errorMsg}`);
console.error(`Failed to download script ${script.slug}:`, error);
}
}
results.newScripts = downloaded;
results.errors.push(...errors);
const downloadResult = await scriptDownloaderService.autoDownloadNewScripts(newScripts);
// @ts-ignore - Type assertion needed for dynamic assignment
results.newScripts = downloadResult.downloaded;
// @ts-ignore - Type assertion needed for dynamic assignment
results.errors.push(...downloadResult.errors);
}
// Update existing scripts
if (settings.autoUpdateExisting && existingScripts.length > 0) {
console.log(`Auto-updating ${existingScripts.length} existing scripts...`);
const updated = [];
const errors = [];
for (const script of existingScripts) {
try {
// Always update existing scripts when auto-update is enabled
const result = await scriptDownloaderService.loadScript(script);
if (result.success) {
updated.push(script); // Store full script object for category grouping
console.log(`Updated script: ${script.name || script.slug}`);
} else {
errors.push(`${script.name || script.slug}: ${result.message}`);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(`${script.name || script.slug}: ${errorMsg}`);
console.error(`Failed to update script ${script.slug}:`, error);
}
}
results.updatedScripts = updated;
results.errors.push(...errors);
if (settings.autoUpdateExisting) {
console.log('Auto-updating existing scripts from synced files...');
const updateResult = await scriptDownloaderService.autoUpdateExistingScripts(allSyncedScripts);
// @ts-ignore - Type assertion needed for dynamic assignment
results.updatedScripts = updateResult.updated;
// @ts-ignore - Type assertion needed for dynamic assignment
results.errors.push(...updateResult.errors);
}
} else {
console.log('No JSON files were synced, skipping script download/update');
@@ -480,19 +310,14 @@ export class AutoSyncService {
}
// Step 3: Send notifications if enabled
if (settings.notificationEnabled && settings.appriseUrls && settings.appriseUrls.length > 0) {
console.log('Sending success notifications...');
if (settings.notificationEnabled && settings.appriseUrls?.length > 0) {
console.log('Sending notifications...');
await this.sendSyncNotification(results);
console.log('Success notifications sent');
}
// Step 4: Update last sync time and clear any previous errors
const lastSyncTime = this.safeToISOString(new Date());
const updatedSettings = {
...settings,
lastAutoSync: lastSyncTime,
lastAutoSyncError: '' // Clear any previous errors on successful sync
};
// Step 4: Update last sync time
const lastSyncTime = new Date().toISOString();
const updatedSettings = { ...settings, lastAutoSync: lastSyncTime };
this.saveSettings(updatedSettings);
const duration = new Date().getTime() - startTime.getTime();
@@ -508,51 +333,27 @@ export class AutoSyncService {
} catch (error) {
console.error('Auto-sync execution failed:', error);
// Check if it's a rate limit error
const isRateLimitError = error instanceof Error && error.name === 'RateLimitError';
const errorMessage = error instanceof Error ? error.message : String(error);
// Send error notification if enabled
const settings = this.loadSettings();
if (settings.notificationEnabled && settings.appriseUrls && settings.appriseUrls.length > 0) {
if (settings.notificationEnabled && settings.appriseUrls?.length > 0) {
try {
const notificationTitle = isRateLimitError ? 'Auto-Sync Rate Limited' : 'Auto-Sync Failed';
const notificationMessage = isRateLimitError
? `GitHub API rate limit exceeded. Please set a GITHUB_TOKEN in your .env file for higher rate limits. Error: ${errorMessage}`
: `Auto-sync failed with error: ${errorMessage}`;
await appriseService.sendNotification(
notificationTitle,
notificationMessage,
settings.appriseUrls || []
'Auto-Sync Failed',
`Auto-sync failed with error: ${error instanceof Error ? error.message : String(error)}`,
settings.appriseUrls
);
} catch (notifError) {
console.error('Failed to send error notification:', notifError);
}
}
// Store the error in settings for UI display
const errorSettings = this.loadSettings();
const errorToStore = isRateLimitError
? `GitHub API rate limit exceeded. Please set a GITHUB_TOKEN in your .env file for higher rate limits.`
: errorMessage;
const updatedErrorSettings = {
...errorSettings,
lastAutoSyncError: errorToStore,
lastAutoSyncErrorTime: this.safeToISOString(new Date())
};
this.saveSettings(updatedErrorSettings);
return {
success: false,
message: errorToStore,
error: errorMessage,
isRateLimitError
message: error instanceof Error ? error.message : String(error),
error: error instanceof Error ? error.message : String(error)
};
} finally {
this.isRunning = false;
globalAutoSyncLock = false; // Release global lock
}
}
@@ -583,12 +384,6 @@ export class AutoSyncService {
const grouped = new Map();
scripts.forEach(script => {
// Validate script object
if (!script || !script.name) {
console.warn('Invalid script object in groupScriptsByCategory, skipping:', script);
return;
}
const scriptCategories = script.categories || [0]; // Default to Miscellaneous (id: 0)
scriptCategories.forEach((/** @type {number} */ catId) => {
const categoryName = categoryMap.get(catId) || 'Miscellaneous';
@@ -620,18 +415,7 @@ export class AutoSyncService {
// @ts-ignore - Dynamic property access
if (results.jsonSync) {
// @ts-ignore - Dynamic property access
const syncedCount = results.jsonSync.count || 0;
// @ts-ignore - Dynamic property access
const syncedFiles = results.jsonSync.syncedFiles || [];
// Calculate up-to-date count (total files - synced files)
// We can't easily get total file count from the sync result, so just show synced count
if (syncedCount > 0) {
body += `JSON Files: ${syncedCount} synced\n`;
} else {
body += `JSON Files: All up-to-date\n`;
}
body += `JSON Files: ${results.jsonSync.syncedCount} synced, ${results.jsonSync.skippedCount} up-to-date\n`;
// @ts-ignore - Dynamic property access
if (results.jsonSync.errors?.length > 0) {
// @ts-ignore - Dynamic property access

View File

@@ -29,24 +29,14 @@ export class GitHubService {
}
private async fetchFromGitHub<T>(endpoint: string): Promise<T> {
const headers: HeadersInit = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
};
// Add GitHub token authentication if available
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
const response = await fetch(`${this.baseUrl}${endpoint}`, { headers });
const response = await fetch(`${this.baseUrl}${endpoint}`, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
},
});
if (!response.ok) {
if (response.status === 403) {
const error = new Error(`GitHub API rate limit exceeded. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`);
error.name = 'RateLimitError';
throw error;
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}

View File

@@ -1,6 +1,276 @@
// JavaScript wrapper for githubJsonService.ts
// This allows the JavaScript autoSyncService.js to import the TypeScript service
import { writeFile, mkdir } from 'fs/promises';
import { readFileSync, readdirSync, statSync, utimesSync } from 'fs';
import { join } from 'path';
import { Buffer } from 'buffer';
import { githubJsonService } from './githubJsonService.ts';
export class GitHubJsonService {
constructor() {
this.baseUrl = null;
this.repoUrl = null;
this.branch = null;
this.jsonFolder = null;
this.localJsonDirectory = null;
this.scriptCache = new Map();
}
export { githubJsonService };
initializeConfig() {
if (this.repoUrl === null) {
// Get environment variables
this.repoUrl = process.env.REPO_URL || "";
this.branch = process.env.REPO_BRANCH || "main";
this.jsonFolder = process.env.JSON_FOLDER || "scripts";
this.localJsonDirectory = join(process.cwd(), 'scripts', 'json');
// Only validate GitHub URL if it's provided
if (this.repoUrl) {
// Extract owner and repo from the URL
const urlMatch = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl);
if (!urlMatch) {
throw new Error(`Invalid GitHub repository URL: ${this.repoUrl}`);
}
const [, owner, repo] = urlMatch;
this.baseUrl = `https://api.github.com/repos/${owner}/${repo}`;
} else {
// Set a dummy base URL if no REPO_URL is provided
this.baseUrl = "";
}
}
}
async fetchFromGitHub(endpoint) {
this.initializeConfig();
const response = await fetch(`${this.baseUrl}${endpoint}`, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
...(process.env.GITHUB_TOKEN && { 'Authorization': `token ${process.env.GITHUB_TOKEN}` })
},
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
async syncJsonFiles() {
try {
this.initializeConfig();
if (!this.baseUrl) {
return {
success: false,
message: 'No GitHub repository configured'
};
}
console.log('Starting fast incremental JSON sync...');
// Ensure local directory exists
await mkdir(this.localJsonDirectory, { recursive: true });
// Step 1: Get file list from GitHub (single API call)
console.log('Fetching file list from GitHub...');
const files = await this.fetchFromGitHub(`/contents/${this.jsonFolder}?ref=${this.branch}`);
if (!Array.isArray(files)) {
throw new Error('Invalid response from GitHub API');
}
const jsonFiles = files.filter(file => file.name.endsWith('.json'));
console.log(`Found ${jsonFiles.length} JSON files in repository`);
// Step 2: Get local file list (fast local operation)
const localFiles = new Map();
try {
console.log(`Looking for local files in: ${this.localJsonDirectory}`);
const localFileList = readdirSync(this.localJsonDirectory);
console.log(`Found ${localFileList.length} files in local directory`);
for (const fileName of localFileList) {
if (fileName.endsWith('.json')) {
const filePath = join(this.localJsonDirectory, fileName);
const stats = statSync(filePath);
localFiles.set(fileName, {
mtime: stats.mtime,
size: stats.size
});
}
}
} catch (error) {
console.log('Error reading local directory:', error.message);
console.log('Directory path:', this.localJsonDirectory);
console.log('No local files found, will download all');
}
console.log(`Found ${localFiles.size} local JSON files`);
// Step 3: Compare and identify files that need syncing
const filesToSync = [];
let skippedCount = 0;
for (const file of jsonFiles) {
const localFile = localFiles.get(file.name);
if (!localFile) {
// File doesn't exist locally
filesToSync.push(file);
console.log(`Missing: ${file.name}`);
} else {
// Compare modification times and sizes
const localMtime = new Date(localFile.mtime);
const remoteMtime = new Date(file.updated_at);
const localSize = localFile.size;
const remoteSize = file.size;
// Sync if remote is newer OR sizes are different (content changed)
if (localMtime < remoteMtime || localSize !== remoteSize) {
filesToSync.push(file);
console.log(`Changed: ${file.name} (${localMtime.toISOString()} -> ${remoteMtime.toISOString()})`);
} else {
skippedCount++;
console.log(`Up-to-date: ${file.name}`);
}
}
}
console.log(`Files to sync: ${filesToSync.length}, Up-to-date: ${skippedCount}`);
// Step 4: Download only the files that need syncing
let syncedCount = 0;
const errors = [];
const syncedFiles = [];
// Process files in batches to avoid overwhelming the API
const batchSize = 10;
for (let i = 0; i < filesToSync.length; i += batchSize) {
const batch = filesToSync.slice(i, i + batchSize);
// Process batch in parallel
const promises = batch.map(async (file) => {
try {
const content = await this.fetchFromGitHub(`/contents/${file.path}?ref=${this.branch}`);
if (content.content) {
// Decode base64 content
const fileContent = Buffer.from(content.content, 'base64').toString('utf-8');
// Write to local file
const localPath = join(this.localJsonDirectory, file.name);
await writeFile(localPath, fileContent, 'utf-8');
// Update file modification time to match remote
const remoteMtime = new Date(file.updated_at);
utimesSync(localPath, remoteMtime, remoteMtime);
syncedCount++;
syncedFiles.push(file.name);
console.log(`Synced: ${file.name}`);
}
} catch (error) {
console.error(`Failed to sync ${file.name}:`, error.message);
errors.push(`${file.name}: ${error.message}`);
}
});
await Promise.all(promises);
// Small delay between batches to be nice to the API
if (i + batchSize < filesToSync.length) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
console.log(`JSON sync completed. Synced ${syncedCount} files, skipped ${skippedCount} files.`);
return {
success: true,
message: `Successfully synced ${syncedCount} JSON files (${skippedCount} up-to-date)`,
syncedCount,
skippedCount,
syncedFiles,
errors
};
} catch (error) {
console.error('JSON sync failed:', error);
return {
success: false,
message: error.message,
error: error.message
};
}
}
async getAllScripts() {
try {
this.initializeConfig();
if (!this.localJsonDirectory) {
return [];
}
const scripts = [];
// Read all JSON files from local directory
const files = readdirSync(this.localJsonDirectory);
const jsonFiles = files.filter(file => file.endsWith('.json'));
for (const file of jsonFiles) {
try {
const filePath = join(this.localJsonDirectory, file);
const content = readFileSync(filePath, 'utf-8');
const script = JSON.parse(content);
if (script && typeof script === 'object') {
scripts.push(script);
}
} catch (error) {
console.error(`Failed to parse ${file}:`, error.message);
}
}
return scripts;
} catch (error) {
console.error('Failed to get all scripts:', error);
return [];
}
}
/**
* Get scripts only for specific JSON files that were synced
*/
async getScriptsForFiles(syncedFiles) {
try {
this.initializeConfig();
if (!this.localJsonDirectory || !syncedFiles || syncedFiles.length === 0) {
return [];
}
const scripts = [];
for (const fileName of syncedFiles) {
try {
const filePath = join(this.localJsonDirectory, fileName);
const content = readFileSync(filePath, 'utf-8');
const script = JSON.parse(content);
if (script && typeof script === 'object') {
scripts.push(script);
}
} catch (error) {
console.error(`Failed to parse ${fileName}:`, error.message);
}
}
return scripts;
} catch (error) {
console.error('Failed to get scripts for synced files:', error);
return [];
}
}
}
export const githubJsonService = new GitHubJsonService();

View File

@@ -1,7 +1,7 @@
import { writeFile, mkdir, readdir } from 'fs/promises';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { env } from '../../env.js';
import type { Script, ScriptCard, GitHubFile } from '../../types/script';
import { env } from '~/env.js';
import type { Script, ScriptCard, GitHubFile } from '~/types/script';
export class GitHubJsonService {
private baseUrl: string | null = null;
@@ -41,25 +41,14 @@ export class GitHubJsonService {
private async fetchFromGitHub<T>(endpoint: string): Promise<T> {
this.initializeConfig();
const headers: HeadersInit = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
};
// Add GitHub token authentication if available
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
const response = await fetch(`${this.baseUrl!}${endpoint}`, { headers });
const response = await fetch(`${this.baseUrl!}${endpoint}`, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
},
});
if (!response.ok) {
if (response.status === 403) {
const error = new Error(`GitHub API rate limit exceeded. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`);
error.name = 'RateLimitError';
throw error;
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
@@ -70,22 +59,8 @@ export class GitHubJsonService {
this.initializeConfig();
const rawUrl = `https://raw.githubusercontent.com/${this.extractRepoPath()}/${this.branch!}/${filePath}`;
const headers: HeadersInit = {
'User-Agent': 'PVEScripts-Local/1.0',
};
// Add GitHub token authentication if available (for raw files, use token in URL or header)
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
const response = await fetch(rawUrl, { headers });
const response = await fetch(rawUrl);
if (!response.ok) {
if (response.status === 403) {
const error = new Error(`GitHub rate limit exceeded while downloading ${filePath}. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`);
error.name = 'RateLimitError';
throw error;
}
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
}
@@ -210,90 +185,48 @@ export class GitHubJsonService {
}
}
async syncJsonFiles(): Promise<{ success: boolean; message: string; count: number; syncedFiles: string[] }> {
async syncJsonFiles(): Promise<{ success: boolean; message: string; count: number }> {
try {
console.log('Starting fast incremental JSON sync...');
// Get all scripts from GitHub (1 API call + raw downloads)
const scripts = await this.getAllScripts();
// Get file list from GitHub
console.log('Fetching file list from GitHub...');
const githubFiles = await this.getJsonFiles();
console.log(`Found ${githubFiles.length} JSON files in repository`);
// Get local files
const localFiles = await this.getLocalJsonFiles();
console.log(`Found ${localFiles.length} files in local directory`);
console.log(`Found ${localFiles.filter(f => f.endsWith('.json')).length} local JSON files`);
// Compare and find files that need syncing
const filesToSync = this.findFilesToSync(githubFiles, localFiles);
console.log(`Found ${filesToSync.length} files that need syncing`);
if (filesToSync.length === 0) {
return {
success: true,
message: 'All JSON files are up to date',
count: 0,
syncedFiles: []
};
}
// Download and save only the files that need syncing
const syncedFiles = await this.syncSpecificFiles(filesToSync);
// Save scripts to local directory
await this.saveScriptsLocally(scripts);
return {
success: true,
message: `Successfully synced ${syncedFiles.length} JSON files from GitHub`,
count: syncedFiles.length,
syncedFiles
message: `Successfully synced ${scripts.length} scripts from GitHub using 1 API call + raw downloads`,
count: scripts.length
};
} catch (error) {
console.error('JSON sync failed:', error);
console.error('Error syncing JSON files:', error);
return {
success: false,
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`,
count: 0,
syncedFiles: []
count: 0
};
}
}
private async getLocalJsonFiles(): Promise<string[]> {
private async saveScriptsLocally(scripts: Script[]): Promise<void> {
this.initializeConfig();
try {
const files = await readdir(this.localJsonDirectory!);
return files.filter(f => f.endsWith('.json'));
} catch {
return [];
}
}
// Ensure the directory exists
await mkdir(this.localJsonDirectory!, { recursive: true });
private findFilesToSync(githubFiles: GitHubFile[], localFiles: string[]): GitHubFile[] {
const localFileSet = new Set(localFiles);
// Return only files that don't exist locally
return githubFiles.filter(ghFile => !localFileSet.has(ghFile.name));
}
private async syncSpecificFiles(filesToSync: GitHubFile[]): Promise<string[]> {
this.initializeConfig();
const syncedFiles: string[] = [];
await mkdir(this.localJsonDirectory!, { recursive: true });
for (const file of filesToSync) {
try {
const script = await this.downloadJsonFile(file.path);
// Save each script as a JSON file
for (const script of scripts) {
const filename = `${script.slug}.json`;
const filePath = join(this.localJsonDirectory!, filename);
await writeFile(filePath, JSON.stringify(script, null, 2), 'utf-8');
syncedFiles.push(filename);
} catch (error) {
console.error(`Failed to sync ${file.name}:`, error);
const content = JSON.stringify(script, null, 2);
await writeFile(filePath, content, 'utf-8');
}
}
return syncedFiles;
}
} catch (error) {
console.error('Error saving scripts locally:', error);
throw new Error('Failed to save scripts locally');
}
}
}
// Singleton instance

View File

@@ -1,6 +0,0 @@
// JavaScript wrapper for localScripts.ts
// This allows the JavaScript autoSyncService.js to import the TypeScript service
import { localScriptsService } from './localScripts.ts';
export { localScriptsService };

View File

@@ -1,18 +1,14 @@
// Real JavaScript implementation for script downloading
import { writeFile, readFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { writeFile, mkdir, access, readFile } from 'fs/promises';
export class ScriptDownloaderService {
constructor() {
this.scriptsDirectory = null;
this.repoUrl = null;
}
initializeConfig() {
if (this.scriptsDirectory === null) {
this.scriptsDirectory = join(process.cwd(), 'scripts');
// Get REPO_URL from environment or use default
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE';
}
}
@@ -27,35 +23,19 @@ export class ScriptDownloaderService {
}
async downloadFileFromGitHub(filePath) {
this.initializeConfig();
if (!this.repoUrl) {
throw new Error('REPO_URL environment variable is not set');
}
// Extract repo path from URL
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl);
if (!match) {
throw new Error('Invalid GitHub repository URL');
}
const [, owner, repo] = match;
const url = `https://raw.githubusercontent.com/${owner}/${repo}/main/${filePath}`;
console.log(`Downloading from GitHub: ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
}
return response.text();
// This is a simplified version - in a real implementation,
// you would fetch the file content from GitHub
// For now, we'll return a placeholder
return `#!/bin/bash
# Downloaded script: ${filePath}
# This is a placeholder - implement actual GitHub file download
echo "Script downloaded: ${filePath}"
`;
}
modifyScriptContent(content) {
// Replace the build.func source line
const oldPattern = /source <\(curl -fsSL https:\/\/raw\.githubusercontent\.com\/community-scripts\/ProxmoxVE\/main\/misc\/build\.func\)/g;
const newPattern = 'SCRIPT_DIR="$(dirname "$0")" \nsource "$SCRIPT_DIR/../core/build.func"';
return content.replace(oldPattern, newPattern);
// Modify script content for CT scripts if needed
return content;
}
async loadScript(script) {
@@ -77,7 +57,6 @@ export class ScriptDownloaderService {
if (fileName) {
// Download from GitHub
console.log(`Downloading script file: ${scriptPath}`);
const content = await this.downloadFileFromGitHub(scriptPath);
// Determine target directory based on script path
@@ -112,6 +91,16 @@ export class ScriptDownloaderService {
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
// Preserve subdirectory structure for VW scripts
const subPath = scriptPath.replace('vw/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
// Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else {
// Handle other script types (fallback to ct directory)
targetDir = 'ct';
@@ -122,7 +111,6 @@ export class ScriptDownloaderService {
}
files.push(`${finalTargetDir}/${fileName}`);
console.log(`Successfully downloaded: ${finalTargetDir}/${fileName}`);
}
}
}
@@ -133,15 +121,12 @@ export class ScriptDownloaderService {
if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`;
try {
console.log(`Downloading install script: install/${installScriptName}`);
const installContent = await this.downloadFileFromGitHub(`install/${installScriptName}`);
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
await writeFile(localInstallPath, installContent, 'utf-8');
files.push(`install/${installScriptName}`);
console.log(`Successfully downloaded: install/${installScriptName}`);
} catch (error) {
} catch {
// Install script might not exist, that's okay
console.log(`Install script not found: install/${installScriptName}`);
}
}
@@ -160,6 +145,78 @@ export class ScriptDownloaderService {
}
}
/**
* Auto-download new scripts that haven't been downloaded yet
*/
async autoDownloadNewScripts(allScripts) {
this.initializeConfig();
const downloaded = [];
const errors = [];
for (const script of allScripts) {
try {
// Check if script is already downloaded
const isDownloaded = await this.isScriptDownloaded(script);
if (!isDownloaded) {
const result = await this.loadScript(script);
if (result.success) {
downloaded.push(script); // Return full script object instead of just name
console.log(`Auto-downloaded new script: ${script.name || script.slug}`);
} else {
errors.push(`${script.name || script.slug}: ${result.message}`);
}
}
} catch (error) {
const errorMsg = `${script.name || script.slug}: ${error instanceof Error ? error.message : 'Unknown error'}`;
errors.push(errorMsg);
console.error(`Failed to auto-download script ${script.slug}:`, error);
}
}
return { downloaded, errors };
}
/**
* Auto-update existing scripts to newer versions
*/
async autoUpdateExistingScripts(allScripts) {
this.initializeConfig();
const updated = [];
const errors = [];
for (const script of allScripts) {
try {
// Check if script is downloaded
const isDownloaded = await this.isScriptDownloaded(script);
if (isDownloaded) {
// Check if update is needed by comparing content
const needsUpdate = await this.scriptNeedsUpdate(script);
if (needsUpdate) {
const result = await this.loadScript(script);
if (result.success) {
updated.push(script); // Return full script object instead of just name
console.log(`Auto-updated script: ${script.name || script.slug}`);
} else {
errors.push(`${script.name || script.slug}: ${result.message}`);
}
}
}
} catch (error) {
const errorMsg = `${script.name || script.slug}: ${error instanceof Error ? error.message : 'Unknown error'}`;
errors.push(errorMsg);
console.error(`Failed to auto-update script ${script.slug}:`, error);
}
}
return { updated, errors };
}
/**
* Check if a script is already downloaded
*/
async isScriptDownloaded(script) {
if (!script.install_methods?.length) return false;
@@ -191,6 +248,12 @@ export class ScriptDownloaderService {
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
const subPath = scriptPath.replace('vw/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else {
targetDir = 'ct';
finalTargetDir = targetDir;
@@ -198,7 +261,7 @@ export class ScriptDownloaderService {
}
try {
await import('fs/promises').then(fs => fs.readFile(filePath, 'utf8'));
await readFile(filePath, 'utf8');
// File exists, continue checking other methods
} catch {
// File doesn't exist, script is not fully downloaded
@@ -212,206 +275,71 @@ export class ScriptDownloaderService {
return true;
}
async checkScriptExists(script) {
this.initializeConfig();
const files = [];
let ctExists = false;
let installExists = false;
/**
* Check if a script needs updating by comparing local and remote content
*/
async scriptNeedsUpdate(script) {
if (!script.install_methods?.length) return false;
try {
// Check scripts based on their install methods
if (script.install_methods?.length) {
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
// Determine target directory based on script path
let targetDir;
let finalTargetDir;
let filePath;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory, targetDir, fileName);
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
const subPath = scriptPath.replace('vw/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory, targetDir, fileName);
}
try {
// Read local content
const localContent = await readFile(filePath, 'utf8');
if (fileName) {
let targetDir;
let finalTargetDir;
let filePath;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory, targetDir, fileName);
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
// Preserve subdirectory structure for tools scripts
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
// Preserve subdirectory structure for VM scripts
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else {
targetDir = 'ct'; // Default fallback
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory, targetDir, fileName);
}
try {
await access(filePath);
files.push(`${finalTargetDir}/${fileName}`);
// Set ctExists for all script types (CT, tools, vm) for UI consistency
if (scriptPath.startsWith('ct/') || scriptPath.startsWith('tools/') || scriptPath.startsWith('vm/')) {
ctExists = true;
}
} catch {
// File doesn't exist
}
}
// Download remote content
const remoteContent = await this.downloadFileFromGitHub(scriptPath);
// Compare content (simple string comparison for now)
// In a more sophisticated implementation, you might want to compare
// file modification times or use content hashing
return localContent !== remoteContent;
} catch {
// If we can't read local or download remote, assume update needed
return true;
}
}
}
// Check for install script for CT scripts
const hasCtScript = script.install_methods?.some(method => method.script?.startsWith('ct/'));
if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`;
const installPath = join(this.scriptsDirectory, 'install', installScriptName);
try {
await access(installPath);
files.push(`install/${installScriptName}`);
installExists = true;
} catch {
// Install script doesn't exist
}
}
return { ctExists, installExists, files };
} catch (error) {
console.error('Error checking script existence:', error);
return { ctExists: false, installExists: false, files: [] };
}
}
async compareScriptContent(script) {
this.initializeConfig();
const differences = [];
let hasDifferences = false;
try {
// First check if any local files exist
const localFilesExist = await this.checkScriptExists(script);
if (!localFilesExist.ctExists && !localFilesExist.installExists) {
// No local files exist, so no comparison needed
return { hasDifferences: false, differences: [] };
}
// If we have local files, proceed with comparison
// Use Promise.all to run comparisons in parallel
const comparisonPromises = [];
// Compare scripts only if they exist locally
if (localFilesExist.ctExists && script.install_methods?.length) {
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
let targetDir;
let finalTargetDir;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
// Preserve subdirectory structure for tools scripts
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
// Preserve subdirectory structure for VM scripts
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
} else {
continue; // Skip unknown script types
}
comparisonPromises.push(
this.compareSingleFile(scriptPath, `${finalTargetDir}/${fileName}`)
.then(result => {
if (result.hasDifferences) {
hasDifferences = true;
differences.push(result.filePath);
}
})
.catch(() => {
// Don't add to differences if there's an error reading files
})
);
}
}
}
}
// Compare install script only if it exists locally
if (localFilesExist.installExists) {
const installScriptName = `${script.slug}-install.sh`;
const installScriptPath = `install/${installScriptName}`;
comparisonPromises.push(
this.compareSingleFile(installScriptPath, installScriptPath)
.then(result => {
if (result.hasDifferences) {
hasDifferences = true;
differences.push(result.filePath);
}
})
.catch(() => {
// Don't add to differences if there's an error reading files
})
);
}
// Wait for all comparisons to complete
await Promise.all(comparisonPromises);
return { hasDifferences, differences };
} catch (error) {
console.error('Error comparing script content:', error);
return { hasDifferences: false, differences: [] };
}
}
async compareSingleFile(remotePath, filePath) {
try {
const localPath = join(this.scriptsDirectory, filePath);
// Read local content
const localContent = await readFile(localPath, 'utf-8');
// Download remote content
const remoteContent = await this.downloadFileFromGitHub(remotePath);
// Apply modification only for CT scripts, not for other script types
let modifiedRemoteContent;
if (remotePath.startsWith('ct/')) {
modifiedRemoteContent = this.modifyScriptContent(remoteContent);
} else {
modifiedRemoteContent = remoteContent; // Don't modify tools or vm scripts
}
// Compare content
const hasDifferences = localContent !== modifiedRemoteContent;
return { hasDifferences, filePath };
} catch (error) {
console.error(`Error comparing file ${filePath}:`, error);
return { hasDifferences: false, filePath };
}
return false;
}
}