Compare commits

...

9 Commits

Author SHA1 Message Date
dependabot[bot]
e0c42a9ea8 build(deps): Bump lucide-react from 0.562.0 to 0.563.0
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.562.0 to 0.563.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.563.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 0.563.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-30 19:55:39 +00:00
github-actions[bot]
351ba09f4e chore: bump version to 0.5.6 (VERSION + package.json) (#482)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-29 15:08:41 +00:00
Michel Roegl-Brunner
580986abfa Merge pull request #481 from community-scripts/fix/466
fix: resolve server from DB for SSH when client sends no ssh_key_path (fixes #466)
2026-01-29 16:03:08 +01:00
Michel Rögl-Brunner
e1d270d52c fix: resolve server from DB for SSH when client sends no ssh_key_path (fixes #466)
- Add resolveServerForSSH() to load full server (including ssh_key_path) from DB
  when WebSocket server has id but key auth without valid ssh_key_path
- Call resolver in handleMessage for all start flows (clone, backup, update,
  shell, script) so Shell and Update over SSH work with key auth
- Extend ServerInfo typedef with auth_type, ssh_key_path for TypeScript
2026-01-29 15:59:58 +01:00
Michel Roegl-Brunner
20dbcae42a Merge pull request #480 from community-scripts/fix/405
fix: delete local JSON files when removed from remote repo (fixes #405)
2026-01-29 15:47:31 +01:00
Michel Rögl-Brunner
8e8c724392 fix: delete local JSON files when removed from remote repo (fixes #405)
- Add deleteLocalFilesRemovedFromRepo() to remove local script JSON files
  that belong to the synced repo but are no longer in the remote list
- Call it in syncJsonFilesForRepo() before find/sync so stale scripts
  no longer appear and download attempts don't 404
- Extend sync return types with deletedFiles; aggregate in syncJsonFiles()
  and include removed count in success message
2026-01-29 15:44:45 +01:00
Michel Roegl-Brunner
201b33ec84 Merge pull request #479 from community-scripts/fix/464
fix: use node-specific Proxmox config paths for VM vs LXC (fixes #464)
2026-01-29 15:31:57 +01:00
Michel Roegl-Brunner
f33504baf5 Merge pull request #478 from community-scripts/fix/312
fix: handle special characters in SSH password/passphrase (Fixes #312)
2026-01-29 15:20:44 +01:00
Michel Rögl-Brunner
4bc5f4d6ad fix: handle special characters in SSH password/passphrase (Fixes #312)
- Use sshpass -f with temp file in transferScriptsFolder so password/passphrase
  never go through shell; safe for {, $, ", etc.
- Pass password via SSH_PASSWORD env in testWithExpect instead of embedding
  in script
- Add ServerForm hint: SSH key recommended; special chars supported
2026-01-29 15:18:41 +01:00
9 changed files with 254 additions and 102 deletions

View File

@@ -1 +1 @@
0.5.5 0.5.6

72
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "pve-scripts-local", "name": "pve-scripts-local",
"version": "0.1.0", "version": "0.5.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pve-scripts-local", "name": "pve-scripts-local",
"version": "0.1.0", "version": "0.5.6",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/adapter-better-sqlite3": "^7.3.0", "@prisma/adapter-better-sqlite3": "^7.3.0",
@@ -32,7 +32,7 @@
"cron-validator": "^1.4.0", "cron-validator": "^1.4.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"lucide-react": "^0.562.0", "lucide-react": "^0.563.0",
"next": ">=16.1.5", "next": ">=16.1.5",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
@@ -196,7 +196,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -606,7 +605,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -650,7 +648,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -660,8 +657,7 @@
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz",
"integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0"
"peer": true
}, },
"node_modules/@electric-sql/pglite-socket": { "node_modules/@electric-sql/pglite-socket": {
"version": "0.0.20", "version": "0.0.20",
@@ -2298,6 +2294,20 @@
"url": "https://dotenvx.com" "url": "https://dotenvx.com"
} }
}, },
"node_modules/@prisma/config/node_modules/magicast": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/parser": "^7.25.4",
"@babel/types": "^7.25.4",
"source-map-js": "^1.2.0"
}
},
"node_modules/@prisma/debug": { "node_modules/@prisma/debug": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz",
@@ -3781,7 +3791,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz",
"integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==", "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.90.20" "@tanstack/query-core": "5.90.20"
}, },
@@ -3891,7 +3900,6 @@
"https://trpc.io/sponsor" "https://trpc.io/sponsor"
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@trpc/server": "11.8.1", "@trpc/server": "11.8.1",
"typescript": ">=5.7.2" "typescript": ">=5.7.2"
@@ -3922,7 +3930,6 @@
"https://trpc.io/sponsor" "https://trpc.io/sponsor"
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"typescript": ">=5.7.2" "typescript": ">=5.7.2"
} }
@@ -3943,7 +3950,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -4129,7 +4137,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -4140,7 +4147,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -4214,7 +4220,6 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0", "@typescript-eslint/types": "8.54.0",
@@ -4869,7 +4874,6 @@
"integrity": "sha512-hRDjg6dlDz7JlZAvjbiCdAJ3SDG+NH8tjZe21vjxfvT2ssYAn72SRXMge3dKKABm3bIJ3C+3wdunIdur8PHEAw==", "integrity": "sha512-hRDjg6dlDz7JlZAvjbiCdAJ3SDG+NH8tjZe21vjxfvT2ssYAn72SRXMge3dKKABm3bIJ3C+3wdunIdur8PHEAw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/utils": "4.0.17", "@vitest/utils": "4.0.17",
"fflate": "^0.8.2", "fflate": "^0.8.2",
@@ -4927,7 +4931,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4978,6 +4981,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -5440,7 +5444,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.25", "baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754", "caniuse-lite": "^1.0.30001754",
@@ -6175,7 +6178,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "17.2.3", "version": "17.2.3",
@@ -6534,7 +6538,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -6720,7 +6723,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -7740,7 +7742,6 @@
"integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
} }
@@ -8514,7 +8515,6 @@
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@acemir/cssom": "^0.9.28", "@acemir/cssom": "^0.9.28",
"@asamuzakjp/dom-selector": "^6.7.6", "@asamuzakjp/dom-selector": "^6.7.6",
@@ -9118,9 +9118,9 @@
} }
}, },
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "0.562.0", "version": "0.563.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz",
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", "integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -9132,6 +9132,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -10899,7 +10900,6 @@
"integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==", "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -10995,6 +10995,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@@ -11010,6 +11011,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -11024,7 +11026,6 @@
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@prisma/config": "7.3.0", "@prisma/config": "7.3.0",
"@prisma/dev": "0.20.0", "@prisma/dev": "0.20.0",
@@ -11213,7 +11214,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -11223,7 +11223,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -11236,7 +11235,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/react-markdown": { "node_modules/react-markdown": {
"version": "10.1.0", "version": "10.1.0",
@@ -12463,8 +12463,7 @@
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
@@ -12566,7 +12565,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -13330,7 +13328,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -13606,7 +13603,6 @@
"integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"typescript": ">=5" "typescript": ">=5"
}, },
@@ -13650,7 +13646,6 @@
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -13744,7 +13739,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -13758,7 +13752,6 @@
"integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/expect": "4.0.17", "@vitest/expect": "4.0.17",
"@vitest/mocker": "4.0.17", "@vitest/mocker": "4.0.17",
@@ -14103,7 +14096,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "pve-scripts-local", "name": "pve-scripts-local",
"version": "0.1.0", "version": "0.5.6",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -48,7 +48,7 @@
"cron-validator": "^1.4.0", "cron-validator": "^1.4.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"lucide-react": "^0.562.0", "lucide-react": "^0.563.0",
"next": ">=16.1.5", "next": ">=16.1.5",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",

View File

@@ -3,6 +3,7 @@ import { parse } from 'url';
import next from 'next'; import next from 'next';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { existsSync } from 'fs';
import { join, resolve } from 'path'; import { join, resolve } from 'path';
import stripAnsi from 'strip-ansi'; import stripAnsi from 'strip-ansi';
import { spawn as ptySpawn } from 'node-pty'; import { spawn as ptySpawn } from 'node-pty';
@@ -56,6 +57,8 @@ const handle = app.getRequestHandler();
* @property {string} user * @property {string} user
* @property {string} password * @property {string} password
* @property {number} [id] * @property {number} [id]
* @property {string} [auth_type]
* @property {string} [ssh_key_path]
*/ */
/** /**
@@ -295,6 +298,20 @@ class ScriptExecutionHandler {
}); });
} }
/**
* Resolve full server from DB when client sends server with id but no ssh_key_path (e.g. for Shell/Update over SSH).
* @param {ServerInfo|null} server - Server from WebSocket message
* @returns {Promise<ServerInfo|null>} Same server or full server from DB
*/
async resolveServerForSSH(server) {
if (!server?.id) return server;
if (server.auth_type === 'key' && (!server.ssh_key_path || !existsSync(server.ssh_key_path))) {
const full = await this.db.getServerById(server.id);
return /** @type {ServerInfo|null} */ (full ?? server);
}
return server;
}
/** /**
* @param {ExtendedWebSocket} ws * @param {ExtendedWebSocket} ws
* @param {WebSocketMessage} message * @param {WebSocketMessage} message
@@ -305,16 +322,21 @@ class ScriptExecutionHandler {
switch (action) { switch (action) {
case 'start': case 'start':
if (scriptPath && executionId) { if (scriptPath && executionId) {
let serverToUse = server;
if (serverToUse?.id) {
serverToUse = await this.resolveServerForSSH(serverToUse) ?? serverToUse;
}
const resolved = serverToUse ?? server;
if (isClone && containerId && storage && server && cloneCount && hostnames && containerType) { if (isClone && containerId && storage && server && cloneCount && hostnames && containerType) {
await this.startSSHCloneExecution(ws, containerId, executionId, storage, server, containerType, cloneCount, hostnames); await this.startSSHCloneExecution(ws, containerId, executionId, storage, /** @type {ServerInfo} */ (resolved), containerType, cloneCount, hostnames);
} else if (isBackup && containerId && storage) { } else if (isBackup && containerId && storage) {
await this.startBackupExecution(ws, containerId, executionId, storage, mode, server); await this.startBackupExecution(ws, containerId, executionId, storage, mode, resolved);
} else if (isUpdate && containerId) { } else if (isUpdate && containerId) {
await this.startUpdateExecution(ws, containerId, executionId, mode, server, backupStorage); await this.startUpdateExecution(ws, containerId, executionId, mode, resolved, backupStorage);
} else if (isShell && containerId) { } else if (isShell && containerId) {
await this.startShellExecution(ws, containerId, executionId, mode, server, containerType); await this.startShellExecution(ws, containerId, executionId, mode, resolved, containerType);
} else { } else {
await this.startScriptExecution(ws, scriptPath, executionId, mode, server, envVars); await this.startScriptExecution(ws, scriptPath, executionId, mode, resolved, envVars);
} }
} else { } else {
this.sendMessage(ws, { this.sendMessage(ws, {

View File

@@ -438,6 +438,11 @@ export function ServerForm({
{errors.password && ( {errors.password && (
<p className="text-destructive mt-1 text-sm">{errors.password}</p> <p className="text-destructive mt-1 text-sm">{errors.password}</p>
)} )}
<p className="text-muted-foreground mt-1 text-xs">
SSH key is recommended when possible. Special characters (e.g.{" "}
<code className="rounded bg-muted px-0.5">{"{ } $ \" '"}</code>) are
supported.
</p>
</div> </div>
)} )}

View File

@@ -1,5 +1,5 @@
// JavaScript wrapper for githubJsonService (for use with node server.js) // JavaScript wrapper for githubJsonService (for use with node server.js)
import { writeFile, mkdir, readdir, readFile } from 'fs/promises'; import { writeFile, mkdir, readdir, readFile, unlink } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
import { repositoryService } from './repositoryService.js'; import { repositoryService } from './repositoryService.js';
import { listDirectory, downloadRawFile } from '../lib/gitProvider/index.js'; import { listDirectory, downloadRawFile } from '../lib/gitProvider/index.js';
@@ -163,25 +163,42 @@ class GitHubJsonService {
const localFiles = await this.getLocalJsonFiles(); const localFiles = await this.getLocalJsonFiles();
console.log(`Found ${localFiles.length} local JSON files`); console.log(`Found ${localFiles.length} local JSON files`);
// Delete local JSON files that belong to this repo but are no longer in the remote
const remoteFilenames = new Set(githubFiles.map((f) => f.name));
const deletedFiles = await this.deleteLocalFilesRemovedFromRepo(repoUrl, remoteFilenames);
if (deletedFiles.length > 0) {
console.log(`Removed ${deletedFiles.length} obsolete JSON file(s) no longer in ${repoUrl}`);
}
const filesToSync = await this.findFilesToSyncForRepo(repoUrl, githubFiles, localFiles); const filesToSync = await this.findFilesToSyncForRepo(repoUrl, githubFiles, localFiles);
console.log(`Found ${filesToSync.length} files that need syncing from ${repoUrl}`); console.log(`Found ${filesToSync.length} files that need syncing from ${repoUrl}`);
if (filesToSync.length === 0) { if (filesToSync.length === 0) {
const msg =
deletedFiles.length > 0
? `All JSON files are up to date for repository: ${repoUrl}. Removed ${deletedFiles.length} obsolete file(s).`
: `All JSON files are up to date for repository: ${repoUrl}`;
return { return {
success: true, success: true,
message: `All JSON files are up to date for repository: ${repoUrl}`, message: msg,
count: 0, count: 0,
syncedFiles: [] syncedFiles: [],
deletedFiles
}; };
} }
const syncedFiles = await this.syncSpecificFiles(repoUrl, filesToSync); const syncedFiles = await this.syncSpecificFiles(repoUrl, filesToSync);
const msg =
deletedFiles.length > 0
? `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}, removed ${deletedFiles.length} obsolete file(s).`
: `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}`;
return { return {
success: true, success: true,
message: `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}`, message: msg,
count: syncedFiles.length, count: syncedFiles.length,
syncedFiles syncedFiles,
deletedFiles
}; };
} catch (error) { } catch (error) {
console.error(`JSON sync failed for ${repoUrl}:`, error); console.error(`JSON sync failed for ${repoUrl}:`, error);
@@ -189,7 +206,8 @@ class GitHubJsonService {
success: false, success: false,
message: `Failed to sync JSON files from ${repoUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`, message: `Failed to sync JSON files from ${repoUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
count: 0, count: 0,
syncedFiles: [] syncedFiles: [],
deletedFiles: []
}; };
} }
} }
@@ -205,13 +223,15 @@ class GitHubJsonService {
success: false, success: false,
message: 'No enabled repositories found', message: 'No enabled repositories found',
count: 0, count: 0,
syncedFiles: [] syncedFiles: [],
deletedFiles: []
}; };
} }
console.log(`Found ${enabledRepos.length} enabled repositories`); console.log(`Found ${enabledRepos.length} enabled repositories`);
const allSyncedFiles = []; const allSyncedFiles = [];
const allDeletedFiles = [];
const processedSlugs = new Set(); const processedSlugs = new Set();
let totalSynced = 0; let totalSynced = 0;
@@ -222,6 +242,7 @@ class GitHubJsonService {
const result = await this.syncJsonFilesForRepo(repo.url); const result = await this.syncJsonFilesForRepo(repo.url);
if (result.success) { if (result.success) {
allDeletedFiles.push(...(result.deletedFiles ?? []));
const newFiles = result.syncedFiles.filter(file => { const newFiles = result.syncedFiles.filter(file => {
const slug = file.replace('.json', ''); const slug = file.replace('.json', '');
if (processedSlugs.has(slug)) { if (processedSlugs.has(slug)) {
@@ -243,11 +264,16 @@ class GitHubJsonService {
await this.updateExistingFilesWithRepositoryUrl(); await this.updateExistingFilesWithRepositoryUrl();
const msg =
allDeletedFiles.length > 0
? `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories, removed ${allDeletedFiles.length} obsolete file(s).`
: `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories`;
return { return {
success: true, success: true,
message: `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories`, message: msg,
count: totalSynced, count: totalSynced,
syncedFiles: allSyncedFiles syncedFiles: allSyncedFiles,
deletedFiles: allDeletedFiles
}; };
} catch (error) { } catch (error) {
console.error('Multi-repository JSON sync failed:', error); console.error('Multi-repository JSON sync failed:', error);
@@ -255,7 +281,8 @@ class GitHubJsonService {
success: false, success: false,
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`, message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`,
count: 0, count: 0,
syncedFiles: [] syncedFiles: [],
deletedFiles: []
}; };
} }
} }
@@ -297,6 +324,32 @@ class GitHubJsonService {
} }
} }
async deleteLocalFilesRemovedFromRepo(repoUrl, remoteFilenames) {
this.initializeConfig();
const localFiles = await this.getLocalJsonFiles();
const deletedFiles = [];
for (const file of localFiles) {
try {
const filePath = join(this.localJsonDirectory, file);
const content = await readFile(filePath, 'utf-8');
const script = JSON.parse(content);
if (script.repository_url === repoUrl && !remoteFilenames.has(file)) {
await unlink(filePath);
const slug = file.replace(/\.json$/, '');
this.scriptCache.delete(slug);
deletedFiles.push(file);
console.log(`Removed obsolete script JSON: ${file} (no longer in ${repoUrl})`);
}
} catch {
// If we can't read or parse the file, skip (do not delete)
}
}
return deletedFiles;
}
async findFilesToSyncForRepo(repoUrl, githubFiles, localFiles) { async findFilesToSyncForRepo(repoUrl, githubFiles, localFiles) {
const filesToSync = []; const filesToSync = [];

View File

@@ -1,4 +1,4 @@
import { writeFile, mkdir, readdir, readFile } from 'fs/promises'; import { writeFile, mkdir, readdir, readFile, unlink } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
import { env } from '../../env.js'; import { env } from '../../env.js';
import type { Script, ScriptCard, GitHubFile } from '../../types/script'; import type { Script, ScriptCard, GitHubFile } from '../../types/script';
@@ -158,7 +158,7 @@ export class GitHubJsonService {
/** /**
* Sync JSON files from a specific repository * Sync JSON files from a specific repository
*/ */
async syncJsonFilesForRepo(repoUrl: string): Promise<{ success: boolean; message: string; count: number; syncedFiles: string[] }> { async syncJsonFilesForRepo(repoUrl: string): Promise<{ success: boolean; message: string; count: number; syncedFiles: string[]; deletedFiles: string[] }> {
try { try {
console.log(`Starting JSON sync from repository: ${repoUrl}`); console.log(`Starting JSON sync from repository: ${repoUrl}`);
@@ -170,28 +170,45 @@ export class GitHubJsonService {
const localFiles = await this.getLocalJsonFiles(); const localFiles = await this.getLocalJsonFiles();
console.log(`Found ${localFiles.length} local JSON files`); console.log(`Found ${localFiles.length} local JSON files`);
// Delete local JSON files that belong to this repo but are no longer in the remote
const remoteFilenames = new Set(githubFiles.map((f) => f.name));
const deletedFiles = await this.deleteLocalFilesRemovedFromRepo(repoUrl, remoteFilenames);
if (deletedFiles.length > 0) {
console.log(`Removed ${deletedFiles.length} obsolete JSON file(s) no longer in ${repoUrl}`);
}
// Compare and find files that need syncing // Compare and find files that need syncing
// For multi-repo support, we need to check if file exists AND if it's from this repo // For multi-repo support, we need to check if file exists AND if it's from this repo
const filesToSync = await this.findFilesToSyncForRepo(repoUrl, githubFiles, localFiles); const filesToSync = await this.findFilesToSyncForRepo(repoUrl, githubFiles, localFiles);
console.log(`Found ${filesToSync.length} files that need syncing from ${repoUrl}`); console.log(`Found ${filesToSync.length} files that need syncing from ${repoUrl}`);
if (filesToSync.length === 0) { if (filesToSync.length === 0) {
const msg =
deletedFiles.length > 0
? `All JSON files are up to date for repository: ${repoUrl}. Removed ${deletedFiles.length} obsolete file(s).`
: `All JSON files are up to date for repository: ${repoUrl}`;
return { return {
success: true, success: true,
message: `All JSON files are up to date for repository: ${repoUrl}`, message: msg,
count: 0, count: 0,
syncedFiles: [] syncedFiles: [],
deletedFiles
}; };
} }
// Download and save only the files that need syncing // Download and save only the files that need syncing
const syncedFiles = await this.syncSpecificFiles(repoUrl, filesToSync); const syncedFiles = await this.syncSpecificFiles(repoUrl, filesToSync);
const msg =
deletedFiles.length > 0
? `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}, removed ${deletedFiles.length} obsolete file(s).`
: `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}`;
return { return {
success: true, success: true,
message: `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}`, message: msg,
count: syncedFiles.length, count: syncedFiles.length,
syncedFiles syncedFiles,
deletedFiles
}; };
} catch (error) { } catch (error) {
console.error(`JSON sync failed for ${repoUrl}:`, error); console.error(`JSON sync failed for ${repoUrl}:`, error);
@@ -199,7 +216,8 @@ export class GitHubJsonService {
success: false, success: false,
message: `Failed to sync JSON files from ${repoUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`, message: `Failed to sync JSON files from ${repoUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
count: 0, count: 0,
syncedFiles: [] syncedFiles: [],
deletedFiles: []
}; };
} }
} }
@@ -207,7 +225,7 @@ export class GitHubJsonService {
/** /**
* Sync JSON files from all enabled repositories (main repo has priority) * Sync JSON files from all enabled repositories (main repo has priority)
*/ */
async syncJsonFiles(): Promise<{ success: boolean; message: string; count: number; syncedFiles: string[] }> { async syncJsonFiles(): Promise<{ success: boolean; message: string; count: number; syncedFiles: string[]; deletedFiles: string[] }> {
try { try {
console.log('Starting multi-repository JSON sync...'); console.log('Starting multi-repository JSON sync...');
@@ -218,13 +236,15 @@ export class GitHubJsonService {
success: false, success: false,
message: 'No enabled repositories found', message: 'No enabled repositories found',
count: 0, count: 0,
syncedFiles: [] syncedFiles: [],
deletedFiles: []
}; };
} }
console.log(`Found ${enabledRepos.length} enabled repositories`); console.log(`Found ${enabledRepos.length} enabled repositories`);
const allSyncedFiles: string[] = []; const allSyncedFiles: string[] = [];
const allDeletedFiles: string[] = [];
const processedSlugs = new Set<string>(); // Track slugs we've already processed const processedSlugs = new Set<string>(); // Track slugs we've already processed
let totalSynced = 0; let totalSynced = 0;
@@ -236,6 +256,7 @@ export class GitHubJsonService {
const result = await this.syncJsonFilesForRepo(repo.url); const result = await this.syncJsonFilesForRepo(repo.url);
if (result.success) { if (result.success) {
allDeletedFiles.push(...(result.deletedFiles ?? []));
// Only count files that weren't already processed from a higher priority repo // Only count files that weren't already processed from a higher priority repo
const newFiles = result.syncedFiles.filter(file => { const newFiles = result.syncedFiles.filter(file => {
const slug = file.replace('.json', ''); const slug = file.replace('.json', '');
@@ -259,11 +280,16 @@ export class GitHubJsonService {
// Also update existing files that don't have repository_url set (backward compatibility) // Also update existing files that don't have repository_url set (backward compatibility)
await this.updateExistingFilesWithRepositoryUrl(); await this.updateExistingFilesWithRepositoryUrl();
const msg =
allDeletedFiles.length > 0
? `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories, removed ${allDeletedFiles.length} obsolete file(s).`
: `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories`;
return { return {
success: true, success: true,
message: `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories`, message: msg,
count: totalSynced, count: totalSynced,
syncedFiles: allSyncedFiles syncedFiles: allSyncedFiles,
deletedFiles: allDeletedFiles
}; };
} catch (error) { } catch (error) {
console.error('Multi-repository JSON sync failed:', error); console.error('Multi-repository JSON sync failed:', error);
@@ -271,7 +297,8 @@ export class GitHubJsonService {
success: false, success: false,
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`, message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`,
count: 0, count: 0,
syncedFiles: [] syncedFiles: [],
deletedFiles: []
}; };
} }
} }
@@ -316,6 +343,36 @@ export class GitHubJsonService {
} }
} }
/**
* Delete local JSON files that belong to this repo but are no longer in the remote list.
* Returns the list of deleted filenames.
*/
private async deleteLocalFilesRemovedFromRepo(repoUrl: string, remoteFilenames: Set<string>): Promise<string[]> {
this.initializeConfig();
const localFiles = await this.getLocalJsonFiles();
const deletedFiles: string[] = [];
for (const file of localFiles) {
try {
const filePath = join(this.localJsonDirectory!, file);
const content = await readFile(filePath, 'utf-8');
const script = JSON.parse(content) as Script;
if (script.repository_url === repoUrl && !remoteFilenames.has(file)) {
await unlink(filePath);
const slug = file.replace(/\.json$/, '');
this.scriptCache.delete(slug);
deletedFiles.push(file);
console.log(`Removed obsolete script JSON: ${file} (no longer in ${repoUrl})`);
}
} catch {
// If we can't read or parse the file, skip (do not delete)
}
}
return deletedFiles;
}
/** /**
* Find files that need syncing for a specific repository * Find files that need syncing for a specific repository
* This checks if file exists locally AND if it's from the same repository * This checks if file exists locally AND if it's from the same repository

View File

@@ -1,6 +1,8 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { spawn as ptySpawn } from 'node-pty'; import { spawn as ptySpawn } from 'node-pty';
import { existsSync } from 'fs'; import { existsSync, writeFileSync, chmodSync, unlinkSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
/** /**
@@ -195,9 +197,22 @@ class SSHExecutionService {
async transferScriptsFolder(server, onData, onError) { async transferScriptsFolder(server, onData, onError) {
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server; const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server;
const cleanupTempFile = (/** @type {string | null} */ tempPath) => {
if (tempPath) {
try {
unlinkSync(tempPath);
} catch (_) {
// ignore
}
}
};
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
/** @type {string | null} */
let tempPath = null;
try { try {
// Build rsync command based on authentication type // Build rsync command based on authentication type.
// Use sshpass -f with a temp file so password/passphrase never go through the shell (safe for special chars like {, $, ").
let rshCommand; let rshCommand;
if (auth_type === 'key') { if (auth_type === 'key') {
if (!ssh_key_path || !existsSync(ssh_key_path)) { if (!ssh_key_path || !existsSync(ssh_key_path)) {
@@ -205,13 +220,19 @@ class SSHExecutionService {
} }
if (ssh_key_passphrase) { if (ssh_key_passphrase) {
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`; tempPath = join(tmpdir(), `sshpass-${process.pid}-${Date.now()}.tmp`);
writeFileSync(tempPath, ssh_key_passphrase);
chmodSync(tempPath, 0o600);
rshCommand = `sshpass -P passphrase -f ${tempPath} ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
} else { } else {
rshCommand = `ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`; rshCommand = `ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
} }
} else { } else {
// Password authentication // Password authentication
rshCommand = `sshpass -p ${password} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`; tempPath = join(tmpdir(), `sshpass-${process.pid}-${Date.now()}.tmp`);
writeFileSync(tempPath, password ?? '');
chmodSync(tempPath, 0o600);
rshCommand = `sshpass -f ${tempPath} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
} }
const rsyncCommand = spawn('rsync', [ const rsyncCommand = spawn('rsync', [
@@ -226,31 +247,31 @@ class SSHExecutionService {
stdio: ['pipe', 'pipe', 'pipe'] stdio: ['pipe', 'pipe', 'pipe']
}); });
rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => { rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => {
// Ensure proper UTF-8 encoding for ANSI colors const output = data.toString('utf8');
const output = data.toString('utf8'); onData(output);
onData(output); });
});
rsyncCommand.stderr.on('data', (/** @type {Buffer} */ data) => { rsyncCommand.stderr.on('data', (/** @type {Buffer} */ data) => {
// Ensure proper UTF-8 encoding for ANSI colors const output = data.toString('utf8');
const output = data.toString('utf8'); onError(output);
onError(output); });
});
rsyncCommand.on('close', (code) => { rsyncCommand.on('close', (code) => {
if (code === 0) { cleanupTempFile(tempPath);
resolve(); if (code === 0) {
} else { resolve();
reject(new Error(`rsync failed with code ${code}`)); } else {
} reject(new Error(`rsync failed with code ${code}`));
}); }
});
rsyncCommand.on('error', (error) => {
reject(error);
});
rsyncCommand.on('error', (error) => {
cleanupTempFile(tempPath);
reject(error);
});
} catch (error) { } catch (error) {
cleanupTempFile(tempPath);
reject(error); reject(error);
} }
}); });

View File

@@ -169,16 +169,17 @@ class SSHService {
const timeout = 10000; const timeout = 10000;
let resolved = false; let resolved = false;
// Pass password via env so it is not embedded in the script (safe for special chars like {, $, ").
const expectScript = `#!/usr/bin/expect -f const expectScript = `#!/usr/bin/expect -f
set timeout 10 set timeout 10
spawn ssh -p ${ssh_port} -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS" spawn ssh -p ${ssh_port} -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS"
expect { expect {
"password:" { "password:" {
send "${password}\r" send "$env(SSH_PASSWORD)\\r"
exp_continue exp_continue
} }
"Password:" { "Password:" {
send "${password}\r" send "$env(SSH_PASSWORD)\\r"
exp_continue exp_continue
} }
"SSH_LOGIN_SUCCESS" { "SSH_LOGIN_SUCCESS" {
@@ -193,7 +194,8 @@ expect {
}`; }`;
const expectCommand = spawn('expect', ['-c', expectScript], { const expectCommand = spawn('expect', ['-c', expectScript], {
stdio: ['pipe', 'pipe', 'pipe'] stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, SSH_PASSWORD: password ?? '' }
}); });
const timer = setTimeout(() => { const timer = setTimeout(() => {