Windows下Hexo使用rsync自动部署

一、操作步骤

按顺序执行以下步骤即可完成配置。

1. 安装cwrsync

下载地址:https://www.itefix.net/cwrsync/client/downloads

为什么选 cwrsync:

  • WSL:不方便与CMD”跨域交流”,还得设置node共享之类的,麻烦
  • cygwin:如果不是常用,也没必要专门安装它
  • cwrsync:是精简了cygwin最小的文件集,只为了rsync,影响最小

解压到目录,例如 D:\apps\cwrsync。设置环境变量:

1
2
CWRSYNCHOME = D:\apps\cwrsync
PATH = %CWRSYNCHOME%\bin;%PATH%

2. 配置ssh免密登录

1
2
ssh-keygen -t rsa
ssh-copy-id user@123.xxx.xxx.xxx

以上生成 id_rsa 和 id_rsa.pub 两个文件。
可选:把以上文件拷贝到 %USERPROFILE%.ssh,可以让 Windows 自带的 OpenSSH 共用。

3. 安装hexo-deployer-rsync

1
npm install hexo-deployer-rsync --save

4. 创建补丁脚本

在项目根目录下创建 tools/patch-deployer.cjs 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
'use strict';

const fs = require('fs');
const path = require('path');

const PKG_NAME = 'hexo-deployer-rsync';
const PATCH_ID = '[patch-deployer]';

const PROJECT_ROOT = path.resolve(__dirname, '..');
const TARGET_FILE = path.join(PROJECT_ROOT, 'node_modules', PKG_NAME, 'lib', 'deployer.js');

const PATCH_VERSION_SIGNAL = '// HEXO_DEPLOYER_RSYNC_PATCHED_BY_WEFUXI';
const PATCH_VERSION = 1;

// exit codes:
// 0 - ok (either no-op or successfully patched)
// 1 - target file missing / io error
// 2 - patch rules could not be applied (before/after mismatch; upstream changed too much)
// 3 - sanity check failed (rules applied partially => output unusable)
const EXIT_OK = 0;

function warn(...a) { console.warn(PATCH_ID, ...a); }
function log(...a) { console.log(PATCH_ID, ...a); }

function read(file) {
return fs.readFileSync(file, 'utf8');
}
function write(file, s) {
fs.writeFileSync(file, s, 'utf8');
}

function regexpEscape(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function replaceOnceOrSkip(state, before, after, title, alreadyFragments) {
const srcBefore = state.src;
// 1) fast idempotent path: if patched indicators are all present -> skip.
if (Array.isArray(alreadyFragments) && alreadyFragments.length
&& alreadyFragments.every(f => srcBefore.indexOf(f) !== -1)) {
state.skipped.push(title);
return;
}
// 2) before matches -> replace exactly once.
const re = (before instanceof RegExp)
? before
: new RegExp(regexpEscape(before));
if (re.test(srcBefore)) {
const matches = srcBefore.match(new RegExp(re.source, (re.flags || '') + (re.global ? '' : 'g'))) || [];
if (matches.length > 1) {
state.failed.push({ title, multi: true, count: matches.length });
return;
}
state.src = srcBefore.replace(re, after);
state.applied.push(title);
return;
}
// 3) before does not match -> if after seems present (by indicator fragments we
// also provided, OR by a plain substring search of the most distinctive
// piece inside "after"), treat as already patched.
if (alreadyFragments && alreadyFragments.length
&& alreadyFragments.every(f => srcBefore.indexOf(f) !== -1)) {
state.skipped.push(title);
return;
}
// As a last-resort indicator, use a 40+ char unique slice taken from the
// middle of "after" (ignoring leading/trailing whitespace) as a signature.
const sig = (typeof after === 'string')
? after.replace(/^\s+|\s+$/g, '').slice(40, 120)
: '';
if (sig && srcBefore.indexOf(sig) !== -1) {
state.skipped.push(title);
return;
}
state.failed.push({
title,
needle: String(before).slice(0, 120) + (String(before).length > 120 ? '…' : ''),
});
}

function ensurePatchedHeader(state) {
if (state.src.indexOf(PATCH_VERSION_SIGNAL) !== -1) {
state.skipped.push('patched-version-header');
return;
}
const lines = state.src.split(/\r?\n/);
// Insert as 2nd line (after 'use strict').
lines.splice(1, 0, PATCH_VERSION_SIGNAL + ' v' + PATCH_VERSION);
state.src = lines.join('\n');
state.applied.push('patched-version-header');
}

function buildPatchSteps() {
return [
{
title: 'add rsync / ssh / ssh_options help lines',
// Before: 'key:' must be IMMEDIATELY followed by the 'verbose:' help line.
// This is how the pristine upstream file is written.
before: / help \+= ' key: <key>\\n';\r?\n help \+= ' verbose:/,
after:
" help += ' key: <key>\\n';\n" +
" help += ' rsync: <rsync command path> # e.g. D:/apps/cwrsync/bin/rsync.exe (Windows)\\n';\n" +
" help += ' ssh: <ssh command path> # e.g. D:/apps/cwrsync/bin/ssh.exe (Windows)\\n';\n" +
" help += ' ssh_options: <extra ssh options> # e.g. \\'-o StrictHostKeyChecking=accept-new\\'\\n';\n" +
" help += ' verbose:",
already: [
"help += ' rsync: <rsync command path>",
"help += ' ssh: <ssh command path>",
"help += ' ssh_options: <extra ssh options>"
]
},
{
title: 'insert rsynccmd + sshcmd vars after params, before if(port)',
before: /\n if \(args\.port && args\.port > 0 && args\.port < 65536\) \{/,
after:
'\n' +
" const cwrsyncHome = process.env.CWRSYNCHOME;\n" +
" let rsynccmd = args.rsync || (cwrsyncHome ? cwrsyncHome + '/bin/rsync.exe' : 'rsync');\n" +
" let sshcmd = args.ssh || (cwrsyncHome ? cwrsyncHome + '/bin/ssh.exe' : 'ssh');\n" +
'\n' +
' if (args.port && args.port > 0 && args.port < 65536) {',
already: [
"const cwrsyncHome = process.env.CWRSYNCHOME",
"let rsynccmd = args.rsync || (cwrsyncHome ? cwrsyncHome + '/bin/rsync.exe' : 'rsync')",
"let sshcmd = args.ssh || (cwrsyncHome ? cwrsyncHome + '/bin/ssh.exe' : 'ssh')"
]
},
{
title: 'replace whole if(port){...} block with unified rshCmd build + ssh_options',
before: /^[ \t]*if \(args\.port && args\.port > 0 && args\.port < 65536\) \{\r?\n[\s\S]*?\n[ \t]*\}[ \t]*\r?\n\s*(?=[ \t]*if \(args\.verbose\))/m,
after: [
" let rshCmd = '';",
' if (args.rsh) {',
" rshCmd = `'${args.rsh}'`;",
" if (args.key) rshCmd += ' -i ' + args.key;",
" if (args.port && args.port > 0 && args.port < 65536) rshCmd += ' -p ' + args.port;",
' } else if (args.port && args.port > 0 && args.port < 65536) {',
' rshCmd = sshcmd;',
" if (args.key) rshCmd += ' -i ' + args.key;",
" rshCmd += ' -p ' + args.port;",
' } else if (args.key) {',
" rshCmd = sshcmd + ' -i ' + args.key;",
' }',
' if (args.ssh_options) {',
' if (!rshCmd) rshCmd = sshcmd;',
" rshCmd += ' ' + args.ssh_options;",
' }',
' if (rshCmd) {',
" params.splice(params.length - 2, 0, '-e', rshCmd);",
' }',
''
].join('\n'),
already: [
"let rshCmd = '';",
'if (args.ssh_options) {',
"params.splice(params.length - 2, 0, '-e', rshCmd);"
]
},
{
title: "create_before_update branch 1st spawn('rsync',...) -> spawn(rsynccmd, ...)",
before: /return spawn\('rsync', params, \{verbose: true\}\)\.then\(\(\) => \{/,
after: 'return spawn(rsynccmd, params, {verbose: true}).then(() => {',
already: [ 'return spawn(rsynccmd, params, {verbose: true}).then(() => {' ]
},
{
title: "create_before_update branch 2nd spawn('rsync',...) -> spawn(rsynccmd, ...)",
before: /return spawn\('rsync', params, \{verbose: true\}\);\r?\n \}\);/,
after: 'return spawn(rsynccmd, params, {verbose: true});\n });',
already: [ 'return spawn(rsynccmd, params, {verbose: true});\n });' ]
},
{
title: "normal exit spawn('rsync',...) -> spawn(rsynccmd, ...)",
before: /return spawn\('rsync', params, \{verbose: true\}\);[ \t]*\r?\n\};[ \t]*\s*$/,
after: 'return spawn(rsynccmd, params, {verbose: true});\n};',
already: [
'return spawn(rsynccmd, params, {verbose: true});\n};'
]
}
];
}

function sanityCheck(state) {
const requiredFragments = [
// Help text lines
"help += ' rsync: <rsync command path>",
"help += ' ssh: <ssh command path>",
"help += ' ssh_options: <extra ssh options>",
// Variables
"const cwrsyncHome = process.env.CWRSYNCHOME",
"let rsynccmd = args.rsync || (cwrsyncHome ? cwrsyncHome + '/bin/rsync.exe' : 'rsync')",
"let sshcmd = args.ssh || (cwrsyncHome ? cwrsyncHome + '/bin/ssh.exe' : 'ssh')",
// Unified rshCmd build
"let rshCmd = ''",
'if (args.ssh_options) {',
"params.splice(params.length - 2, 0, '-e', rshCmd)",
// 3 spawn(rsynccmd,...)
'return spawn(rsynccmd, params, {verbose: true}).then(() => {',
// The next two both end in spawn(rsynccmd, params, {verbose: true}); so we
// count occurrences instead.
];
const missing = requiredFragments.filter(s => state.src.indexOf(s) === -1);
if (missing.length) {
state.failed.push({ title: 'sanity-check: expected fragments missing', missing });
return false;
}
// spawn(rsynccmd, params, {verbose: true}); must appear exactly 3 times
const spawnFixed = (state.src.match(/spawn\(rsynccmd, params, \{verbose: true\}\)/g) || []).length;
if (spawnFixed !== 3) {
state.failed.push({ title: 'sanity-check: spawn(rsynccmd,...) count!=3', got: spawnFixed });
return false;
}
// no leftover spawn('rsync', ...) (hardcoded original) allowed
if (/spawn\(['"]rsync['"]\s*,/.test(state.src)) {
state.failed.push({ title: 'sanity-check: leftover hardcoded spawn(\'rsync\',...) calls remain' });
return false;
}
return true;
}

function main() {
if (!fs.existsSync(path.dirname(TARGET_FILE))) {
warn(PKG_NAME + ' not installed yet. Skipping patch; re-run after `npm install`.');
return EXIT_OK;
}
if (!fs.existsSync(TARGET_FILE)) {
warn('target file missing: ' + TARGET_FILE);
return 1;
}

const state = {
src: read(TARGET_FILE),
applied: [],
skipped: [],
failed: [],
};

ensurePatchedHeader(state);

const steps = buildPatchSteps();
for (const step of steps) {
replaceOnceOrSkip(state, step.before, step.after, step.title, step.already);
}

if (state.applied.length) log('applied patches :', state.applied.join(' | '));
if (state.skipped.length) log('skipped (idempotent):', state.skipped.join(' | '));
if (state.failed.length) {
warn('the following patch rules could not be auto-applied to');
warn(' ' + TARGET_FILE);
warn('This probably means hexo-deployer-rsync upgraded and the upstream source');
warn('shape changed. Please review the rules in scripts/patch-deployer.cjs and fix.');
for (const f of state.failed) {
if (f.missing) {
warn(' - [' + f.title + '] missing fragments: ' + f.missing.join(' ; '));
} else if (f.multi) {
warn(' - [' + f.title + '] expected 1 match but found ' + f.count + '; ambiguous abort.');
} else {
warn(' - [' + f.title + '] needle not found: ' + (f.needle || ''));
}
}
const diffPath = path.join(PROJECT_ROOT, 'node_modules', '.' + PKG_NAME + '-deployer.patch.failed.tmp');
try { write(diffPath, state.src); } catch (_) {}
warn('(current state after partial apply was dumped to ' + diffPath + ' for inspection.)');
return 2;
}

if (!sanityCheck(state)) {
warn('sanity check failed. The patched file is missing required fragments.');
warn('DO NOT use the generated file; fix the patch rules. Dumping state...');
const diffPath = path.join(PROJECT_ROOT, 'node_modules', '.' + PKG_NAME + '-deployer.patch.failed.tmp');
try { write(diffPath, state.src); } catch (_) {}
warn(' dumped -> ' + diffPath);
return 3;
}

write(TARGET_FILE, state.src);

log('ok -> ' + TARGET_FILE);
return EXIT_OK;
}

process.exit(main());

将以上内容保存为 [你的hexo项目根目录]/tools/patch-deployer.cjs

然后在 package.jsonscripts 中添加:

1
2
3
4
5
6
{
"scripts": {
"patch:deployer": "node tools/patch-deployer.cjs",
"postinstall": "node tools/patch-deployer.cjs"
}
}

手动执行一次确认补丁生效(可选):

1
npm run patch:deployer

看到 [patch-deployer] ok -> ...deployer.js 即表示成功。

什么时候会自动执行?
由于配置了 postinstall 钩子,以下场景都会自动触发补丁,无需手动干预:

  • npm install(初次安装所有依赖)
  • npm install hexo-deployer-rsync(单独安装或升级该包)
  • npm ci(CI 环境安装)

也就是说,只要依赖安装过程正常完成,补丁就已经生效了。上面的手动执行只是用来验证或排查问题。

5. 配置 _config.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
deploy:
type: rsync
host: 111.222.xxx.xxx
user: xxx
root: /remote/path
rsync: D:/apps/cwrsync/bin/rsync.exe
ssh: D:/apps/cwrsync/bin/ssh.exe
ssh_options: '-o StrictHostKeyChecking=accept-new'
port: 22
delete: false
progress: true
args: '--chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r'
verbose: true

配置说明:

  • rsync / ssh:指向 cwrsync 的可执行文件路径(Windows下必须指定)
  • ssh_options:额外SSH参数,如跳过首次连接确认
  • args:rsync额外参数,这里是为了正确设置远端文件权限(否则web服务器可能403)
  • delete: false:不删除远端多余文件(按需开启)

6. 部署

1
hexo deploy

二、补丁原理(太长不看版)

需要知其所以然的看官,您这边请:

为什么要打补丁

hexo-deployer-rsync@3.0.0 原版存在以下问题:

  1. rsync 和 ssh 命令路径硬编码为 'rsync''ssh',Windows 下找不到 cwrsync
  2. 不支持 ssh_options 参数(如 -o StrictHostKeyChecking=accept-new
  3. 端口/密钥/rsh 的组合逻辑不够灵活

补丁做了什么

补丁脚本 tools/patch-deployer.cjsnode_modules/hexo-deployer-rsync/lib/deployer.js 做以下修改:

1. 增加命令路径变量

params 数组定义之后插入:

1
2
3
4
+  let rsynccmd = 'rsync';
+ if (args.rsync) rsynccmd = args.rsync;
+ let sshcmd = 'ssh';
+ if (args.ssh) sshcmd = args.ssh;

2. 重写 rsh 命令构建逻辑

原版只有简单的 if (port) 判断,补丁替换为统一的 rshCmd 构建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
+  let rshCmd = '';
+ if (args.rsh) {
+ rshCmd = `'${args.rsh}'`;
+ if (args.key) rshCmd += ' -i ' + args.key;
+ if (args.port && args.port > 0 && args.port < 65536) rshCmd += ' -p ' + args.port;
+ } else if (args.port && args.port > 0 && args.port < 65536) {
+ rshCmd = sshcmd;
+ if (args.key) rshCmd += ' -i ' + args.key;
+ rshCmd += ' -p ' + args.port;
+ } else if (args.key) {
+ rshCmd = sshcmd + ' -i ' + args.key;
+ }
+ if (args.ssh_options) {
+ if (!rshCmd) rshCmd = sshcmd;
+ rshCmd += ' ' + args.ssh_options;
+ }
+ if (rshCmd) {
+ params.splice(params.length - 2, 0, '-e', rshCmd);
+ }

3. 替换 spawn 调用

所有 spawn('rsync', params, ...) 替换为 spawn(rsynccmd, params, ...),使自定义路径生效。

补丁的特性

  • 自动执行:通过 postinstall 钩子,每次 npm install 后自动打补丁
  • 幂等安全:重复运行不会重复修改,已打过的补丁会被跳过
  • 健全性检查:如果上游版本升级导致代码结构变化,补丁会报错退出而非静默产生错误文件

旧方案(已废弃)

之前的做法是手动修改 node_modules/hexo-deployer-rsync/lib/deployer.js,每次 npm install 后修改就丢失了,需要重新手动改。

现在通过补丁脚本彻底解决了这个问题。


参考资料
https://hexo.io/zh-cn/docs/one-command-deployment
https://github.com/hexojs/hexo-deployer-rsync