feat: first cut at publishing tool
This commit is contained in:
163
package-lock.json
generated
163
package-lock.json
generated
@@ -37,11 +37,13 @@
|
||||
"liquidjs": "^10.24.0",
|
||||
"marked-react": "^3.0.2",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"node-scp": "^0.0.25",
|
||||
"pyodide": "^0.29.3",
|
||||
"react": "^19.2.4",
|
||||
"react-arborist": "^3.4.3",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"rsyncwrapper": "^3.1.0",
|
||||
"sharp": "^0.34.5",
|
||||
"simple-git": "^3.31.1",
|
||||
"snowball-stemmers": "^0.6.0",
|
||||
@@ -184,6 +186,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -762,6 +765,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
|
||||
"integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
@@ -838,6 +842,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz",
|
||||
"integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
@@ -859,6 +864,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.13.tgz",
|
||||
"integrity": "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -954,6 +960,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
@@ -994,6 +1001,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
@@ -1348,7 +1356,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1370,7 +1377,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1387,7 +1393,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1402,7 +1407,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -3959,6 +3963,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.0.tgz",
|
||||
"integrity": "sha512-TLjSU9Otdpq0SpKHl1tD1Nc9MKhrsZbCFGot3EbCxRa8m1E5R1mMwoOjKMMM31IyF7fr+hPNHLpYfwbMKNusmg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@libsql/core": "^0.17.0",
|
||||
"@libsql/hrana-client": "^0.9.0",
|
||||
@@ -5165,8 +5170,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -5401,6 +5405,7 @@
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -5411,6 +5416,7 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -5518,6 +5524,7 @@
|
||||
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.0",
|
||||
"@typescript-eslint/types": "8.56.0",
|
||||
@@ -5897,6 +5904,7 @@
|
||||
"integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.0.18",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -6083,6 +6091,7 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6116,6 +6125,7 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6475,6 +6485,15 @@
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assert-plus": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
|
||||
@@ -6630,6 +6649,15 @@
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
@@ -6692,6 +6720,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -6748,6 +6777,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buildcheck": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
|
||||
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/builder-util": {
|
||||
"version": "26.4.1",
|
||||
"resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.4.1.tgz",
|
||||
@@ -7364,6 +7402,20 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/cpu-features": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buildcheck": "~0.0.6",
|
||||
"nan": "^2.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/crc": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz",
|
||||
@@ -7387,8 +7439,7 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -7525,7 +7576,8 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/d3-cloud": {
|
||||
"version": "1.2.8",
|
||||
@@ -7803,6 +7855,7 @@
|
||||
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.7.0",
|
||||
"builder-util": "26.4.1",
|
||||
@@ -7904,8 +7957,7 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
@@ -8378,7 +8430,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -8399,7 +8450,6 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -8426,16 +8476,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -8543,6 +8583,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -8620,6 +8661,7 @@
|
||||
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -10127,6 +10169,7 @@
|
||||
"integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.31",
|
||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||
@@ -10462,7 +10505,6 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -11732,7 +11774,6 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -11745,6 +11786,7 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -11787,6 +11829,13 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.25.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
|
||||
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
|
||||
@@ -11988,6 +12037,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-scp": {
|
||||
"version": "0.0.25",
|
||||
"resolved": "https://registry.npmjs.org/node-scp/-/node-scp-0.0.25.tgz",
|
||||
"integrity": "sha512-vodQgKRuxRjrzwTXyRl0JsrF+mjc/P4M2jwIjg4gpdbNC3KBWptTVN9b5PqludLsN402MdKN6lGydlmXGGv1YA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ssh2": "^1.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nopt": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
|
||||
@@ -12306,6 +12364,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12426,7 +12485,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -12444,7 +12502,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -12465,7 +12522,6 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -12481,7 +12537,6 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -12636,6 +12691,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
@@ -12669,6 +12725,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
@@ -12702,6 +12759,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz",
|
||||
"integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
@@ -12792,6 +12850,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -12857,6 +12916,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -12886,8 +12946,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
@@ -13192,7 +13251,6 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -13270,6 +13328,15 @@
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rsyncwrapper": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/rsyncwrapper/-/rsyncwrapper-3.1.0.tgz",
|
||||
"integrity": "sha512-lIygqLFXwNn4TzK6zqij7Ey70+pRzcqOZFAXsJYUdw5rOeAP8ce1Xqt0BZQFUCTZCPBTOLc1wDfCzEk0T9Nxng==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
@@ -13305,7 +13372,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-filename": {
|
||||
@@ -13672,6 +13738,23 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"asn1": "^0.2.6",
|
||||
"bcrypt-pbkdf": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cpu-features": "~0.0.10",
|
||||
"nan": "^2.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssri": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz",
|
||||
@@ -13935,7 +14018,6 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -14215,7 +14297,8 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"devOptional": true,
|
||||
"license": "0BSD"
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
@@ -14730,6 +14813,12 @@
|
||||
"@mixmark-io/domino": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -14765,6 +14854,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -15063,6 +15153,7 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -15622,6 +15713,7 @@
|
||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.18",
|
||||
"@vitest/mocker": "4.0.18",
|
||||
@@ -15699,6 +15791,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz",
|
||||
"integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.28",
|
||||
"@vue/compiler-sfc": "3.5.28",
|
||||
|
||||
@@ -97,11 +97,13 @@
|
||||
"liquidjs": "^10.24.0",
|
||||
"marked-react": "^3.0.2",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"node-scp": "^0.0.25",
|
||||
"pyodide": "^0.29.3",
|
||||
"react": "^19.2.4",
|
||||
"react-arborist": "^3.4.3",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"rsyncwrapper": "^3.1.0",
|
||||
"sharp": "^0.34.5",
|
||||
"simple-git": "^3.31.1",
|
||||
"snowball-stemmers": "^0.6.0",
|
||||
|
||||
370
src/main/engine/PublishEngine.ts
Normal file
370
src/main/engine/PublishEngine.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { constants as fsConstants } from 'fs';
|
||||
import { Client as scpClient, type ScpClient } from 'node-scp';
|
||||
import rsync from 'rsyncwrapper';
|
||||
|
||||
export interface PublishCredentials {
|
||||
sshHost: string;
|
||||
sshUser: string;
|
||||
sshRemotePath: string;
|
||||
sshMode: 'scp' | 'rsync';
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
htmlFilesUploaded: number;
|
||||
thumbnailFilesUploaded: number;
|
||||
mediaFilesUploaded: number;
|
||||
filesSkipped: number;
|
||||
}
|
||||
|
||||
type ProgressCallback = (progress: number, message: string) => void;
|
||||
|
||||
/** Files with these extensions are excluded from media uploads (metadata sidecars). */
|
||||
const META_EXTENSION = '.meta';
|
||||
|
||||
export class PublishEngine extends EventEmitter {
|
||||
private projectId: string | null = null;
|
||||
private dataDir: string | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
setProjectContext(projectId: string, dataDir: string): void {
|
||||
this.projectId = projectId;
|
||||
this.dataDir = dataDir;
|
||||
}
|
||||
|
||||
async uploadSite(
|
||||
credentials: PublishCredentials,
|
||||
onProgress: ProgressCallback,
|
||||
): Promise<PublishResult> {
|
||||
if (!this.dataDir || !this.projectId) {
|
||||
throw new Error('No project context set');
|
||||
}
|
||||
this.validateCredentials(credentials);
|
||||
|
||||
const htmlDir = path.join(this.dataDir, 'html');
|
||||
const thumbnailsDir = path.join(this.dataDir, 'thumbnails');
|
||||
const mediaDir = path.join(this.dataDir, 'media');
|
||||
|
||||
// Verify the generated site exists
|
||||
await this.ensureDirectoryExists(htmlDir, 'Generated site not found. Please render the site first.');
|
||||
|
||||
const result: PublishResult = {
|
||||
htmlFilesUploaded: 0,
|
||||
thumbnailFilesUploaded: 0,
|
||||
mediaFilesUploaded: 0,
|
||||
filesSkipped: 0,
|
||||
};
|
||||
|
||||
if (credentials.sshMode === 'rsync') {
|
||||
await this.uploadViaRsync(credentials, htmlDir, thumbnailsDir, mediaDir, result, onProgress);
|
||||
} else {
|
||||
await this.uploadViaScp(credentials, htmlDir, thumbnailsDir, mediaDir, result, onProgress);
|
||||
}
|
||||
|
||||
onProgress(100, 'Upload complete');
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── SCP mode ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async uploadViaScp(
|
||||
credentials: PublishCredentials,
|
||||
htmlDir: string,
|
||||
thumbnailsDir: string,
|
||||
mediaDir: string,
|
||||
result: PublishResult,
|
||||
onProgress: ProgressCallback,
|
||||
): Promise<void> {
|
||||
const client = await scpClient({
|
||||
host: credentials.sshHost,
|
||||
username: credentials.sshUser,
|
||||
agent: process.env.SSH_AUTH_SOCK,
|
||||
});
|
||||
|
||||
try {
|
||||
// Phase 1: html/ → remote root (0–33%)
|
||||
onProgress(0, 'Uploading HTML files...');
|
||||
const htmlResult = await this.scpUploadDirectory(
|
||||
client,
|
||||
htmlDir,
|
||||
credentials.sshRemotePath,
|
||||
(p, msg) => onProgress(Math.round(p * 0.33), msg),
|
||||
);
|
||||
result.htmlFilesUploaded = htmlResult.uploaded;
|
||||
result.filesSkipped += htmlResult.skipped;
|
||||
|
||||
// Phase 2: thumbnails/ → remote/thumbnails/ (33–66%)
|
||||
onProgress(33, 'Uploading thumbnails...');
|
||||
if (await this.directoryExists(thumbnailsDir)) {
|
||||
const thumbResult = await this.scpUploadDirectory(
|
||||
client,
|
||||
thumbnailsDir,
|
||||
path.posix.join(credentials.sshRemotePath, 'thumbnails'),
|
||||
(p, msg) => onProgress(33 + Math.round(p * 0.33), msg),
|
||||
);
|
||||
result.thumbnailFilesUploaded = thumbResult.uploaded;
|
||||
result.filesSkipped += thumbResult.skipped;
|
||||
}
|
||||
|
||||
// Phase 3: media/ → remote/media/ (66–99%), excluding .meta files
|
||||
onProgress(66, 'Uploading media files...');
|
||||
if (await this.directoryExists(mediaDir)) {
|
||||
const mediaResult = await this.scpUploadDirectory(
|
||||
client,
|
||||
mediaDir,
|
||||
path.posix.join(credentials.sshRemotePath, 'media'),
|
||||
(p, msg) => onProgress(66 + Math.round(p * 0.33), msg),
|
||||
(name) => !name.endsWith(META_EXTENSION),
|
||||
);
|
||||
result.mediaFilesUploaded = mediaResult.uploaded;
|
||||
result.filesSkipped += mediaResult.skipped;
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively upload a local directory to a remote path via SCP/SFTP.
|
||||
* Only uploads files that are newer than the remote version.
|
||||
*/
|
||||
private async scpUploadDirectory(
|
||||
client: ScpClient,
|
||||
localDir: string,
|
||||
remoteDir: string,
|
||||
onProgress: ProgressCallback,
|
||||
fileFilter?: (name: string) => boolean,
|
||||
): Promise<{ uploaded: number; skipped: number }> {
|
||||
// Collect all files first for progress tracking
|
||||
const files = await this.collectFiles(localDir, '', fileFilter);
|
||||
let uploaded = 0;
|
||||
let skipped = 0;
|
||||
|
||||
if (files.length === 0) {
|
||||
onProgress(100, 'No files to upload');
|
||||
return { uploaded: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
// Ensure remote directory exists
|
||||
await this.scpEnsureDir(client, remoteDir);
|
||||
|
||||
// Track created directories to avoid redundant mkdir calls
|
||||
const createdDirs = new Set<string>();
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const relativePath = files[i];
|
||||
const localPath = path.join(localDir, relativePath);
|
||||
const remotePath = path.posix.join(remoteDir, relativePath.split(path.sep).join('/'));
|
||||
|
||||
// Ensure parent directory exists on remote
|
||||
const remoteParent = path.posix.dirname(remotePath);
|
||||
if (!createdDirs.has(remoteParent)) {
|
||||
await this.scpEnsureDir(client, remoteParent);
|
||||
createdDirs.add(remoteParent);
|
||||
}
|
||||
|
||||
// Check if we need to upload (compare mtime)
|
||||
const localStat = await fs.stat(localPath);
|
||||
const needsUpload = await this.scpNeedsUpload(client, remotePath, localStat.mtimeMs);
|
||||
|
||||
if (needsUpload) {
|
||||
await client.uploadFile(localPath, remotePath);
|
||||
uploaded++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
|
||||
const progress = ((i + 1) / files.length) * 100;
|
||||
onProgress(progress, `${uploaded} uploaded, ${skipped} skipped (${i + 1}/${files.length})`);
|
||||
}
|
||||
|
||||
return { uploaded, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a local file is newer than the remote file.
|
||||
* Returns true if upload is needed.
|
||||
*/
|
||||
private async scpNeedsUpload(
|
||||
client: ScpClient,
|
||||
remotePath: string,
|
||||
localMtimeMs: number,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const remoteStat = await client.stat(remotePath);
|
||||
// SSH2 Stats.mtime is in seconds, localMtimeMs is in milliseconds
|
||||
const remoteMtimeMs = remoteStat.mtime * 1000;
|
||||
return localMtimeMs > remoteMtimeMs;
|
||||
} catch {
|
||||
// File doesn't exist on remote → needs upload
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private async scpEnsureDir(client: ScpClient, remoteDir: string): Promise<void> {
|
||||
try {
|
||||
await client.mkdir(remoteDir, undefined, { recursive: true });
|
||||
} catch {
|
||||
// Directory may already exist
|
||||
}
|
||||
}
|
||||
|
||||
// ── rsync mode ────────────────────────────────────────────────────────────
|
||||
|
||||
private async uploadViaRsync(
|
||||
credentials: PublishCredentials,
|
||||
htmlDir: string,
|
||||
thumbnailsDir: string,
|
||||
mediaDir: string,
|
||||
result: PublishResult,
|
||||
onProgress: ProgressCallback,
|
||||
): Promise<void> {
|
||||
const remoteDest = `${credentials.sshUser}@${credentials.sshHost}:${credentials.sshRemotePath}`;
|
||||
|
||||
// Phase 1: html/ → remote root (0–33%)
|
||||
onProgress(0, 'Syncing HTML files via rsync...');
|
||||
const htmlCount = await this.rsyncDirectory(
|
||||
htmlDir + '/',
|
||||
remoteDest + '/',
|
||||
);
|
||||
result.htmlFilesUploaded = htmlCount;
|
||||
onProgress(33, 'HTML sync complete');
|
||||
|
||||
// Phase 2: thumbnails/ → remote/thumbnails/ (33–66%)
|
||||
if (await this.directoryExists(thumbnailsDir)) {
|
||||
onProgress(33, 'Syncing thumbnails via rsync...');
|
||||
const thumbCount = await this.rsyncDirectory(
|
||||
thumbnailsDir + '/',
|
||||
remoteDest + '/thumbnails/',
|
||||
);
|
||||
result.thumbnailFilesUploaded = thumbCount;
|
||||
}
|
||||
onProgress(66, 'Thumbnails sync complete');
|
||||
|
||||
// Phase 3: media/ → remote/media/ (66–99%), excluding .meta files
|
||||
if (await this.directoryExists(mediaDir)) {
|
||||
onProgress(66, 'Syncing media files via rsync...');
|
||||
const mediaCount = await this.rsyncDirectory(
|
||||
mediaDir + '/',
|
||||
remoteDest + '/media/',
|
||||
['*.meta'],
|
||||
);
|
||||
result.mediaFilesUploaded = mediaCount;
|
||||
}
|
||||
onProgress(99, 'Media sync complete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run rsync for a directory with incremental (--update --times) and recursive transfer.
|
||||
* Returns estimated file count from stdout.
|
||||
*/
|
||||
private rsyncDirectory(
|
||||
src: string,
|
||||
dest: string,
|
||||
exclude?: string[],
|
||||
): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
rsync(
|
||||
{
|
||||
src,
|
||||
dest,
|
||||
ssh: true,
|
||||
recursive: true,
|
||||
times: true,
|
||||
args: ['--update', '--compress'],
|
||||
exclude: exclude || [],
|
||||
},
|
||||
(error, stdout, _stderr, _cmd) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
// Count uploaded files from rsync stdout (each transferred file gets a line)
|
||||
const lines = stdout.trim().split('\n').filter((l: string) => l.length > 0);
|
||||
resolve(lines.length);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Recursively collect all file paths relative to baseDir, with optional filter.
|
||||
*/
|
||||
private async collectFiles(
|
||||
baseDir: string,
|
||||
prefix: string,
|
||||
filter?: (name: string) => boolean,
|
||||
): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(baseDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return files;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = prefix ? path.join(prefix, entry.name) : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
const subFiles = await this.collectFiles(
|
||||
path.join(baseDir, entry.name),
|
||||
relativePath,
|
||||
filter,
|
||||
);
|
||||
files.push(...subFiles);
|
||||
} else if (entry.isFile()) {
|
||||
if (!filter || filter(entry.name)) {
|
||||
files.push(relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private validateCredentials(credentials: PublishCredentials): void {
|
||||
if (!credentials.sshHost?.trim()) {
|
||||
throw new Error('SSH host is required');
|
||||
}
|
||||
if (!credentials.sshUser?.trim()) {
|
||||
throw new Error('SSH user is required');
|
||||
}
|
||||
if (!credentials.sshRemotePath?.trim()) {
|
||||
throw new Error('Remote path is required');
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureDirectoryExists(dirPath: string, errorMessage: string): Promise<void> {
|
||||
try {
|
||||
await fs.access(dirPath, fsConstants.F_OK);
|
||||
} catch {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private async directoryExists(dirPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(dirPath, fsConstants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
let publishEngine: PublishEngine | null = null;
|
||||
|
||||
export function getPublishEngine(): PublishEngine {
|
||||
if (!publishEngine) {
|
||||
publishEngine = new PublishEngine();
|
||||
}
|
||||
return publishEngine;
|
||||
}
|
||||
@@ -108,3 +108,9 @@ export {
|
||||
type CreateScriptInput,
|
||||
type UpdateScriptInput,
|
||||
} from './ScriptEngine';
|
||||
export {
|
||||
PublishEngine,
|
||||
getPublishEngine,
|
||||
type PublishCredentials,
|
||||
type PublishResult,
|
||||
} from './PublishEngine';
|
||||
|
||||
@@ -18,6 +18,7 @@ import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_WEB_CONTENTS_ACTIONS, type AppMenuA
|
||||
import { generateBlogmarkBookmarkletSource } from '../shared/blogmark';
|
||||
import { registerMetadataDiffHandlers } from './metadataDiffHandlers';
|
||||
import { registerBlogHandlers } from './blogHandlers';
|
||||
import { registerPublishHandlers } from './publishHandlers';
|
||||
|
||||
/**
|
||||
* Wrap an IPC handler so that "Database is closing" errors during shutdown
|
||||
@@ -1433,6 +1434,7 @@ export function registerIpcHandlers(): void {
|
||||
|
||||
registerMetadataDiffHandlers(safeHandle);
|
||||
registerBlogHandlers(safeHandle);
|
||||
registerPublishHandlers(safeHandle);
|
||||
|
||||
// ============ Event Forwarding ============
|
||||
|
||||
|
||||
30
src/main/ipc/publishHandlers.ts
Normal file
30
src/main/ipc/publishHandlers.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { getProjectEngine } from '../engine/ProjectEngine';
|
||||
import { getPublishEngine, type PublishCredentials } from '../engine/PublishEngine';
|
||||
import { taskManager } from '../engine/TaskManager';
|
||||
|
||||
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
|
||||
|
||||
export function registerPublishHandlers(safeHandle: SafeHandle): void {
|
||||
safeHandle('publish:uploadSite', async (_event: unknown, credentials: PublishCredentials) => {
|
||||
const projectEngine = getProjectEngine();
|
||||
const project = await projectEngine.getActiveProject();
|
||||
if (!project) {
|
||||
throw new Error('No active project');
|
||||
}
|
||||
|
||||
const publishEngine = getPublishEngine();
|
||||
publishEngine.setProjectContext(project.id, project.dataPath!);
|
||||
|
||||
return taskManager.runTask({
|
||||
id: `publish-upload-${Date.now()}`,
|
||||
name: 'Upload Site',
|
||||
groupId: 'publish',
|
||||
groupName: 'Site Publishing',
|
||||
execute: async (onProgress) => {
|
||||
return publishEngine.uploadSite(credentials, (progress, message) => {
|
||||
onProgress(progress, message);
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -272,6 +272,12 @@ export const electronAPI: ElectronAPI = {
|
||||
regenerateCalendar: () => ipcRenderer.invoke('blog:regenerateCalendar'),
|
||||
},
|
||||
|
||||
// Site publishing (SCP/rsync)
|
||||
publish: {
|
||||
uploadSite: (credentials: { sshHost: string; sshUser: string; sshRemotePath: string; sshMode: 'scp' | 'rsync' }) =>
|
||||
ipcRenderer.invoke('publish:uploadSite', credentials),
|
||||
},
|
||||
|
||||
menu: {
|
||||
get: () => ipcRenderer.invoke('menu:get'),
|
||||
save: (menu: import('./shared/electronApi').MenuDocument) => ipcRenderer.invoke('menu:save', menu),
|
||||
|
||||
@@ -709,6 +709,19 @@ export interface ElectronAPI {
|
||||
applyValidation: (report: SiteValidationReport) => Promise<SiteValidationApplyResult>;
|
||||
regenerateCalendar: () => Promise<CalendarRegenerationResult>;
|
||||
};
|
||||
publish: {
|
||||
uploadSite: (credentials: {
|
||||
sshHost: string;
|
||||
sshUser: string;
|
||||
sshRemotePath: string;
|
||||
sshMode: 'scp' | 'rsync';
|
||||
}) => Promise<{
|
||||
htmlFilesUploaded: number;
|
||||
thumbnailFilesUploaded: number;
|
||||
mediaFilesUploaded: number;
|
||||
filesSkipped: number;
|
||||
}>;
|
||||
};
|
||||
menu: {
|
||||
get: () => Promise<MenuDocument>;
|
||||
save: (menu: MenuDocument) => Promise<MenuDocument>;
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.item.generateSitemap": "Site rendern",
|
||||
"menu.item.regenerateCalendar": "Kalender neu erzeugen",
|
||||
"menu.item.validateSite": "Website validieren",
|
||||
"menu.item.uploadSite": "Website hochladen",
|
||||
"menu.item.about": "Über Blogging Desktop Server",
|
||||
"menu.item.openDocumentation": "Dokumentation öffnen",
|
||||
"menu.item.openApiDocumentation": "API-Dokumentation",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.item.generateSitemap": "Render Site",
|
||||
"menu.item.regenerateCalendar": "Regenerate Calendar",
|
||||
"menu.item.validateSite": "Validate Site",
|
||||
"menu.item.uploadSite": "Upload Site",
|
||||
"menu.item.about": "About Blogging Desktop Server",
|
||||
"menu.item.openDocumentation": "Open Documentation",
|
||||
"menu.item.openApiDocumentation": "API documentation",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.item.generateSitemap": "Renderizar sitio",
|
||||
"menu.item.regenerateCalendar": "Regenerar calendario",
|
||||
"menu.item.validateSite": "Validar sitio",
|
||||
"menu.item.uploadSite": "Subir sitio",
|
||||
"menu.item.about": "Acerca de Blogging Desktop Server",
|
||||
"menu.item.openDocumentation": "Abrir documentación",
|
||||
"menu.item.openApiDocumentation": "Documentación API",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.item.generateSitemap": "Rendre le site",
|
||||
"menu.item.regenerateCalendar": "Régénérer le calendrier",
|
||||
"menu.item.validateSite": "Valider le site",
|
||||
"menu.item.uploadSite": "Publier le site",
|
||||
"menu.item.about": "À propos de Blogging Desktop Server",
|
||||
"menu.item.openDocumentation": "Ouvrir la documentation",
|
||||
"menu.item.openApiDocumentation": "Documentation API",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"menu.item.generateSitemap": "Renderizza sito",
|
||||
"menu.item.regenerateCalendar": "Rigenera calendario",
|
||||
"menu.item.validateSite": "Valida sito",
|
||||
"menu.item.uploadSite": "Carica sito",
|
||||
"menu.item.about": "Informazioni su Blogging Desktop Server",
|
||||
"menu.item.openDocumentation": "Apri documentazione",
|
||||
"menu.item.openApiDocumentation": "Documentazione API",
|
||||
|
||||
@@ -36,6 +36,7 @@ export type AppMenuAction =
|
||||
| 'generateSitemap'
|
||||
| 'regenerateCalendar'
|
||||
| 'validateSite'
|
||||
| 'uploadSite'
|
||||
| 'openDocumentation'
|
||||
| 'openApiDocumentation'
|
||||
| 'about'
|
||||
@@ -132,6 +133,8 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
|
||||
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
|
||||
{ label: 'menu.item.regenerateCalendar', action: 'regenerateCalendar' },
|
||||
{ label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Shift+L' },
|
||||
{ label: '', action: 'blog-separator-4', separator: true },
|
||||
{ label: 'menu.item.uploadSite', action: 'uploadSite', accelerator: 'CmdOrCtrl+Shift+U' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -169,6 +172,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> =
|
||||
generateSitemap: 'menu:generateSitemap',
|
||||
regenerateCalendar: 'menu:regenerateCalendar',
|
||||
validateSite: 'menu:validateSite',
|
||||
uploadSite: 'menu:uploadSite',
|
||||
openDocumentation: 'menu:openDocumentation',
|
||||
openApiDocumentation: 'menu:openApiDocumentation',
|
||||
about: 'menu:about',
|
||||
|
||||
@@ -495,6 +495,27 @@ const App: React.FC = () => {
|
||||
}) || (() => {})
|
||||
);
|
||||
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('menu:uploadSite', async () => {
|
||||
try {
|
||||
const stored = localStorage.getItem('bds-credentials');
|
||||
if (!stored) {
|
||||
showToast.error(tr('app.uploadSiteNoCredentials'));
|
||||
return;
|
||||
}
|
||||
const credentials = JSON.parse(stored);
|
||||
if (!credentials.sshHost || !credentials.sshUser || !credentials.sshRemotePath) {
|
||||
showToast.error(tr('app.uploadSiteNoCredentials'));
|
||||
return;
|
||||
}
|
||||
await window.electronAPI?.publish.uploadSite(credentials);
|
||||
} catch (error) {
|
||||
console.error('Site upload failed:', error);
|
||||
showToast.error(tr('app.uploadSiteFailed'));
|
||||
}
|
||||
}) || (() => {})
|
||||
);
|
||||
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('menu:openDocumentation', () => {
|
||||
openSingletonToolTab(openTab, 'documentation');
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
"app.textReindexFailed": "Text-Neuindizierung fehlgeschlagen",
|
||||
"app.sitemapGenerationFailed": "Sitemap-Erstellung fehlgeschlagen",
|
||||
"app.calendarRegenerationFailed": "Kalender-Neuerstellung fehlgeschlagen",
|
||||
"app.uploadSiteFailed": "Website-Upload fehlgeschlagen",
|
||||
"app.uploadSiteNoCredentials": "Bitte konfigurieren Sie zuerst die SSH-Zugangsdaten in den Einstellungen.",
|
||||
"app.previewOpenFailed": "Ausgewählte Beitragsvorschau konnte nicht geöffnet werden",
|
||||
"app.metadataDiff": "Metadaten-Diff",
|
||||
"app.importComplete": "Import abgeschlossen: {posts} Beiträge, {media} Mediendateien",
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
"app.textReindexFailed": "Text reindex failed",
|
||||
"app.sitemapGenerationFailed": "Sitemap generation failed",
|
||||
"app.calendarRegenerationFailed": "Calendar regeneration failed",
|
||||
"app.uploadSiteFailed": "Site upload failed",
|
||||
"app.uploadSiteNoCredentials": "Please configure SSH publishing credentials in Settings first.",
|
||||
"app.previewOpenFailed": "Failed to open selected post preview",
|
||||
"app.metadataDiff": "Metadata Diff",
|
||||
"app.importComplete": "Import complete: {posts} posts, {media} media files",
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
"app.textReindexFailed": "La reindexación de texto falló",
|
||||
"app.sitemapGenerationFailed": "La generación del sitemap falló",
|
||||
"app.calendarRegenerationFailed": "La regeneración del calendario falló",
|
||||
"app.uploadSiteFailed": "Error al subir el sitio",
|
||||
"app.uploadSiteNoCredentials": "Configure primero las credenciales SSH en Configuración.",
|
||||
"app.previewOpenFailed": "No se pudo abrir la vista previa de la entrada seleccionada",
|
||||
"app.metadataDiff": "Diferencia de Metadatos",
|
||||
"app.importComplete": "Importación completada: {posts} entradas, {media} archivos multimedia",
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
"app.databaseRebuildFailed": "Échec de la reconstruction de la base de données",
|
||||
"app.textReindexFailed": "Échec de la réindexation du texte",
|
||||
"app.sitemapGenerationFailed": "Échec de la génération du sitemap",
|
||||
"app.calendarRegenerationFailed": "Échec de la régénération du calendrier",
|
||||
"app.previewOpenFailed": "Impossible d’ouvrir l’aperçu de l’article sélectionné",
|
||||
"app.calendarRegenerationFailed": "Échec de la régénération du calendrier", "app.uploadSiteFailed": "Échec de la publication du site",
|
||||
"app.uploadSiteNoCredentials": "Veuillez d'abord configurer les identifiants SSH dans les paramètres.", "app.previewOpenFailed": "Impossible d’ouvrir l’aperçu de l’article sélectionné",
|
||||
"app.metadataDiff": "Diff Métadonnées",
|
||||
"app.importComplete": "Import terminé : {posts} articles, {media} fichiers média",
|
||||
"siteValidation.tabTitle": "Validation du site",
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
"app.databaseRebuildFailed": "Ricostruzione database non riuscita",
|
||||
"app.textReindexFailed": "Reindicizzazione testo non riuscita",
|
||||
"app.sitemapGenerationFailed": "Generazione sitemap non riuscita",
|
||||
"app.calendarRegenerationFailed": "Rigenerazione del calendario non riuscita",
|
||||
"app.previewOpenFailed": "Impossibile aprire l’anteprima del post selezionato",
|
||||
"app.calendarRegenerationFailed": "Rigenerazione del calendario non riuscita", "app.uploadSiteFailed": "Caricamento del sito non riuscito",
|
||||
"app.uploadSiteNoCredentials": "Configurare prima le credenziali SSH nelle impostazioni.", "app.previewOpenFailed": "Impossibile aprire l’anteprima del post selezionato",
|
||||
"app.metadataDiff": "Diff Metadati",
|
||||
"app.importComplete": "Import completato: {posts} post, {media} file multimediali",
|
||||
"siteValidation.tabTitle": "Validazione sito",
|
||||
|
||||
379
tests/engine/PublishEngine.test.ts
Normal file
379
tests/engine/PublishEngine.test.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* PublishEngine Unit Tests
|
||||
*
|
||||
* Tests the site upload engine that publishes generated site content
|
||||
* via SCP or rsync to a remote server.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import path from 'path';
|
||||
import { PublishEngine, type PublishCredentials, type PublishResult } from '../../src/main/engine/PublishEngine';
|
||||
|
||||
// Hoist mock variables so they're available inside vi.mock factories
|
||||
const {
|
||||
mockStat, mockUploadFile, mockMkdir, mockClose, mockScpClient,
|
||||
mockReaddir, mockFsStat, mockAccess,
|
||||
mockRsync,
|
||||
} = vi.hoisted(() => {
|
||||
const mockStat = vi.fn();
|
||||
const mockUploadFile = vi.fn();
|
||||
const mockMkdir = vi.fn();
|
||||
const mockClose = vi.fn();
|
||||
const mockScpClient = {
|
||||
stat: mockStat,
|
||||
uploadFile: mockUploadFile,
|
||||
mkdir: mockMkdir,
|
||||
close: mockClose,
|
||||
};
|
||||
const mockReaddir = vi.fn();
|
||||
const mockFsStat = vi.fn();
|
||||
const mockAccess = vi.fn();
|
||||
const mockRsync = vi.fn((_options: any, callback: any) => {
|
||||
callback(null, '', '', 'rsync command');
|
||||
});
|
||||
return {
|
||||
mockStat, mockUploadFile, mockMkdir, mockClose, mockScpClient,
|
||||
mockReaddir, mockFsStat, mockAccess,
|
||||
mockRsync,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock node-scp
|
||||
vi.mock('node-scp', () => ({
|
||||
Client: vi.fn().mockResolvedValue(mockScpClient),
|
||||
default: vi.fn().mockResolvedValue(mockScpClient),
|
||||
}));
|
||||
|
||||
// Mock rsyncwrapper
|
||||
vi.mock('rsyncwrapper', () => ({
|
||||
default: mockRsync,
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: {
|
||||
readdir: (...args: any[]) => mockReaddir(...args),
|
||||
stat: (...args: any[]) => mockFsStat(...args),
|
||||
access: (...args: any[]) => mockAccess(...args),
|
||||
},
|
||||
readdir: (...args: any[]) => mockReaddir(...args),
|
||||
stat: (...args: any[]) => mockFsStat(...args),
|
||||
access: (...args: any[]) => mockAccess(...args),
|
||||
}));
|
||||
|
||||
// Mock fs
|
||||
vi.mock('fs', () => ({
|
||||
default: { constants: { F_OK: 0 } },
|
||||
constants: { F_OK: 0 },
|
||||
}));
|
||||
|
||||
describe('PublishEngine', () => {
|
||||
let engine: PublishEngine;
|
||||
const dataDir = '/projects/test-project';
|
||||
|
||||
const defaultCredentials: PublishCredentials = {
|
||||
sshHost: 'example.com',
|
||||
sshUser: 'deploy',
|
||||
sshRemotePath: '/var/www/html',
|
||||
sshMode: 'scp',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
engine = new PublishEngine();
|
||||
engine.setProjectContext('test-project', dataDir);
|
||||
|
||||
// Default: directories exist, files are regular files
|
||||
mockAccess.mockResolvedValue(undefined);
|
||||
mockFsStat.mockResolvedValue({ isDirectory: () => false, isFile: () => true, mtimeMs: Date.now() });
|
||||
mockReaddir.mockResolvedValue([]);
|
||||
mockStat.mockRejectedValue(new Error('No such file')); // Remote file doesn't exist → upload
|
||||
mockUploadFile.mockResolvedValue(undefined);
|
||||
mockMkdir.mockResolvedValue(undefined);
|
||||
mockClose.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor and project context', () => {
|
||||
it('should be instantiated via getPublishEngine singleton', async () => {
|
||||
const { getPublishEngine } = await import('../../src/main/engine/PublishEngine');
|
||||
const e1 = getPublishEngine();
|
||||
const e2 = getPublishEngine();
|
||||
expect(e1).toBe(e2);
|
||||
});
|
||||
|
||||
it('should throw if no project context is set', async () => {
|
||||
const noContextEngine = new PublishEngine();
|
||||
await expect(
|
||||
noContextEngine.uploadSite(defaultCredentials, vi.fn()),
|
||||
).rejects.toThrow('No project context');
|
||||
});
|
||||
});
|
||||
|
||||
describe('credential validation', () => {
|
||||
it('should throw if sshHost is empty', async () => {
|
||||
await expect(
|
||||
engine.uploadSite({ ...defaultCredentials, sshHost: '' }, vi.fn()),
|
||||
).rejects.toThrow('SSH host is required');
|
||||
});
|
||||
|
||||
it('should throw if sshUser is empty', async () => {
|
||||
await expect(
|
||||
engine.uploadSite({ ...defaultCredentials, sshUser: '' }, vi.fn()),
|
||||
).rejects.toThrow('SSH user is required');
|
||||
});
|
||||
|
||||
it('should throw if sshRemotePath is empty', async () => {
|
||||
await expect(
|
||||
engine.uploadSite({ ...defaultCredentials, sshRemotePath: '' }, vi.fn()),
|
||||
).rejects.toThrow('Remote path is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('directory validation', () => {
|
||||
it('should throw if html directory does not exist', async () => {
|
||||
mockAccess.mockImplementation(async (p: string) => {
|
||||
if ((p as string).endsWith('html')) throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
await expect(
|
||||
engine.uploadSite(defaultCredentials, vi.fn()),
|
||||
).rejects.toThrow('Generated site not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SCP mode upload', () => {
|
||||
it('should upload html files to remote root', async () => {
|
||||
// html/ contains index.html
|
||||
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
|
||||
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
|
||||
return [{ name: 'index.html', isDirectory: () => false, isFile: () => true }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
mockFsStat.mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
mtimeMs: Date.now(),
|
||||
});
|
||||
|
||||
const onProgress = vi.fn();
|
||||
const result = await engine.uploadSite(defaultCredentials, onProgress);
|
||||
|
||||
expect(result.htmlFilesUploaded).toBeGreaterThanOrEqual(0);
|
||||
expect(onProgress).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should upload thumbnail files to remote thumbnails/', async () => {
|
||||
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
|
||||
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
|
||||
return [];
|
||||
}
|
||||
if (dir === path.join(dataDir, 'thumbnails') && opts?.withFileTypes) {
|
||||
return [{ name: 'thumb1.jpg', isDirectory: () => false, isFile: () => true }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
mockFsStat.mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
mtimeMs: Date.now(),
|
||||
});
|
||||
|
||||
const result = await engine.uploadSite(defaultCredentials, vi.fn());
|
||||
|
||||
expect(result.thumbnailFilesUploaded).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should only upload image files from media, not .meta sidecars', async () => {
|
||||
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
|
||||
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
|
||||
return [];
|
||||
}
|
||||
if (dir === path.join(dataDir, 'thumbnails') && opts?.withFileTypes) {
|
||||
return [];
|
||||
}
|
||||
if (dir === path.join(dataDir, 'media') && opts?.withFileTypes) {
|
||||
return [
|
||||
{ name: 'photo.jpg', isDirectory: () => false, isFile: () => true },
|
||||
{ name: 'photo.jpg.meta', isDirectory: () => false, isFile: () => true },
|
||||
{ name: 'document.pdf', isDirectory: () => false, isFile: () => true },
|
||||
{ name: 'document.pdf.meta', isDirectory: () => false, isFile: () => true },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
mockFsStat.mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
mtimeMs: Date.now(),
|
||||
});
|
||||
|
||||
const result = await engine.uploadSite(defaultCredentials, vi.fn());
|
||||
|
||||
// Should upload photo.jpg and document.pdf, but NOT .meta files
|
||||
expect(result.mediaFilesUploaded).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should skip files that are not newer than remote', async () => {
|
||||
const remoteTime = Date.now() / 1000; // SSH stats use seconds
|
||||
const localTimeOlder = (remoteTime - 100) * 1000; // local is older (ms)
|
||||
|
||||
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
|
||||
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
|
||||
return [{ name: 'old.html', isDirectory: () => false, isFile: () => true }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
mockFsStat.mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
mtimeMs: localTimeOlder,
|
||||
});
|
||||
|
||||
// Remote file exists and is newer
|
||||
mockStat.mockResolvedValue({ mtime: remoteTime });
|
||||
|
||||
const result = await engine.uploadSite(defaultCredentials, vi.fn());
|
||||
|
||||
// File should be skipped since remote is newer
|
||||
expect(mockUploadFile).not.toHaveBeenCalled();
|
||||
expect(result.filesSkipped).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should upload files that are newer than remote', async () => {
|
||||
const remoteTime = Date.now() / 1000 - 100; // Remote is 100s old
|
||||
const localTimeNewer = Date.now(); // local is current (ms)
|
||||
|
||||
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
|
||||
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
|
||||
return [{ name: 'new.html', isDirectory: () => false, isFile: () => true }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
mockFsStat.mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
mtimeMs: localTimeNewer,
|
||||
});
|
||||
|
||||
mockStat.mockResolvedValue({ mtime: remoteTime });
|
||||
|
||||
const result = await engine.uploadSite(defaultCredentials, vi.fn());
|
||||
|
||||
expect(mockUploadFile).toHaveBeenCalled();
|
||||
expect(result.htmlFilesUploaded).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should recurse into subdirectories', async () => {
|
||||
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
|
||||
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
|
||||
return [
|
||||
{ name: '2026', isDirectory: () => true, isFile: () => false },
|
||||
];
|
||||
}
|
||||
if (dir === path.join(dataDir, 'html', '2026') && opts?.withFileTypes) {
|
||||
return [
|
||||
{ name: 'post.html', isDirectory: () => false, isFile: () => true },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
mockFsStat.mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
mtimeMs: Date.now(),
|
||||
});
|
||||
|
||||
const result = await engine.uploadSite(defaultCredentials, vi.fn());
|
||||
|
||||
expect(mockMkdir).toHaveBeenCalled(); // Should create remote subdir
|
||||
expect(result.htmlFilesUploaded).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return a complete PublishResult', async () => {
|
||||
mockReaddir.mockResolvedValue([]);
|
||||
|
||||
const result = await engine.uploadSite(defaultCredentials, vi.fn());
|
||||
|
||||
expect(result).toHaveProperty('htmlFilesUploaded');
|
||||
expect(result).toHaveProperty('thumbnailFilesUploaded');
|
||||
expect(result).toHaveProperty('mediaFilesUploaded');
|
||||
expect(result).toHaveProperty('filesSkipped');
|
||||
expect(typeof result.htmlFilesUploaded).toBe('number');
|
||||
expect(typeof result.thumbnailFilesUploaded).toBe('number');
|
||||
expect(typeof result.mediaFilesUploaded).toBe('number');
|
||||
expect(typeof result.filesSkipped).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rsync mode upload', () => {
|
||||
const rsyncCredentials: PublishCredentials = {
|
||||
...defaultCredentials,
|
||||
sshMode: 'rsync',
|
||||
};
|
||||
|
||||
it('should call rsync for html directory', async () => {
|
||||
const rsync = (await import('rsyncwrapper')).default;
|
||||
|
||||
const result = await engine.uploadSite(rsyncCredentials, vi.fn());
|
||||
|
||||
expect(rsync).toHaveBeenCalled();
|
||||
expect(result.htmlFilesUploaded).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should use --update and --times flags for incremental transfer', async () => {
|
||||
const rsync = (await import('rsyncwrapper')).default;
|
||||
|
||||
await engine.uploadSite(rsyncCredentials, vi.fn());
|
||||
|
||||
// Check that rsync was called with update semantics
|
||||
const calls = vi.mocked(rsync).mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
for (const [options] of calls) {
|
||||
expect(options.args).toContain('--update');
|
||||
expect(options.times).toBe(true);
|
||||
expect(options.recursive).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should exclude .meta files when syncing media', async () => {
|
||||
const rsync = (await import('rsyncwrapper')).default;
|
||||
|
||||
await engine.uploadSite(rsyncCredentials, vi.fn());
|
||||
|
||||
const calls = vi.mocked(rsync).mock.calls;
|
||||
// Find the media sync call (dest contains /media)
|
||||
const mediaCall = calls.find(([opts]) =>
|
||||
typeof opts.dest === 'string' && opts.dest.includes('/media'),
|
||||
);
|
||||
if (mediaCall) {
|
||||
expect(mediaCall[0].exclude).toContain('*.meta');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('progress reporting', () => {
|
||||
it('should report progress through all three phases', async () => {
|
||||
mockReaddir.mockResolvedValue([]);
|
||||
|
||||
const onProgress = vi.fn();
|
||||
await engine.uploadSite(defaultCredentials, onProgress);
|
||||
|
||||
// Should have called onProgress at least once per phase
|
||||
expect(onProgress).toHaveBeenCalled();
|
||||
const progressValues = onProgress.mock.calls.map(([p]: [number]) => p);
|
||||
// Should reach 100
|
||||
expect(progressValues[progressValues.length - 1]).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user