ssh-release 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0 - 2026-06-26
4
+
5
+ ### Added
6
+
7
+ - 增加 `ssh-release rollback --json --progress`,回滚时按 NDJSON 输出 `lock`、`switch`、`cleanup`、`verify` 阶段状态。
8
+ - 增强回滚后远端校验,成功结果会输出 `verified` 与 `verification`,确认目标版本目录、`current` 指向和远端锁清理状态。
9
+
10
+ ## 1.2.0 - 2026-06-26
11
+
12
+ ### Added
13
+
14
+ - 增加首次发布指南,覆盖初始化、配置、预检、发布、列表和回滚流程。
15
+ - 增加平台依赖说明,补充 macOS、Ubuntu/Debian、Windows 和 CI 中的 `sshpass`、OpenSSH、`tar`、远端 hash 命令要求。
16
+ - `ssh-release init` 成功后输出下一步操作提示。
17
+ - 配置文件缺失和 `sshpass` 缺失时输出更具体的下一步提示。
18
+ - `ssh-release doctor` 增加本地 `tar`、`ssh`、`scp`、`sshpass` 和远端 hash 命令检查。
19
+
3
20
  ## 1.1.0 - 2026-06-26
4
21
 
5
22
  ### Added
package/README.md CHANGED
@@ -11,18 +11,20 @@
11
11
  已实现:
12
12
 
13
13
  - `ssh-release init`:生成 `ssh-release.config.ts` 配置模板。
14
- - `ssh-release doctor`:检查配置、本地源路径、SSH 连接、远程目录、远端锁和远端 `tar`。
14
+ - `ssh-release doctor`:检查配置、本地源路径、本地命令、SSH 连接、远程目录、远端 hash、远端锁和远端 `tar`。
15
15
  - `ssh-release deploy`:发布本地文件或目录。
16
16
  - `ssh-release list`:查看远程版本和当前版本。
17
17
  - `ssh-release rollback [version]`:回滚到上一个版本或指定版本。
18
18
  - `ssh-release unlock`:查看远端锁,并在显式确认锁路径后删除锁。
19
19
  - `--json`:输出单行 JSON,便于 CI/CD 解析。
20
20
  - `deploy --json --progress`:发布时输出 NDJSON 阶段进度,便于 CI/CD 展示实时状态。
21
+ - `rollback --json --progress`:回滚时输出 NDJSON 阶段进度,便于 CI/CD 展示实时状态。
21
22
  - `deploy --plan`:不连接远端、不修改服务器,预览上传、切换、清理和校验计划。
22
23
  - `rollback --plan`:连接远端读取版本状态,但不修改服务器,预览回滚切换计划。
23
24
  - 失败下一步提示:常见锁、回滚目标、发布校验和 SSH 错误会提示下一步操作。
24
25
  - 发布 manifest:每次发布生成 `manifest.json`,记录版本、发布时间、本地来源、文件清单、文件大小和 SHA-256。
25
26
  - 发布后远端校验:确认版本目录或目标目录存在、`current` 已指向新版本、`manifest.json` hash 匹配、远端锁已清理。
27
+ - 回滚后远端校验:确认目标版本目录存在、`current` 已指向目标版本、远端锁已清理。
26
28
  - `release` 模式:上传压缩包、远端解压、切换 `current`、清理旧版本。
27
29
  - `overwrite` 模式:直接覆盖发布到目标目录。
28
30
  - 远端 `tar` 解压失败时回退逐文件上传。
@@ -55,6 +57,9 @@ ssh-release --help
55
57
  ssh-release --version
56
58
  ```
57
59
 
60
+ 首次接入步骤见 [docs/quick-start.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/quick-start.md)。
61
+ 本机和 CI 依赖见 [docs/platform-requirements.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/platform-requirements.md)。
62
+
58
63
  ## 本地开发
59
64
 
60
65
  ```bash
@@ -177,7 +182,9 @@ source files -> package -> upload -> release -> activate -> rollback
177
182
  ssh-release doctor
178
183
  ```
179
184
 
180
- `doctor` 会检查 `.ssh-release.lock`。没有锁时正常通过;如果发现锁,会以 `warn` 显示锁路径、pid、创建时间和安全清理提示。
185
+ `doctor` 会先检查本地 `tar`、`ssh`、`scp`,使用密码登录时还会检查 `sshpass`。本地检查失败时不会继续连接远端。
186
+
187
+ `doctor` 也会检查 `.ssh-release.lock`。没有锁时正常通过;如果发现锁,会以 `warn` 显示锁路径、pid、创建时间和安全清理提示。
181
188
 
182
189
  需要先查看发布计划但不修改服务器时:
183
190
 
@@ -280,6 +287,7 @@ ssh-release deploy --json --progress
280
287
  ssh-release doctor --json
281
288
  ssh-release list --json
282
289
  ssh-release rollback --json
290
+ ssh-release rollback --json --progress
283
291
  ssh-release unlock --json
284
292
  ```
285
293
 
@@ -295,10 +303,11 @@ ssh-release unlock --json
295
303
  {"ok":false,"command":"deploy","error":"错误信息"}
296
304
  ```
297
305
 
298
- 发布时需要持续读取阶段状态,可以使用:
306
+ 发布或回滚时需要持续读取阶段状态,可以使用:
299
307
 
300
308
  ```bash
301
309
  ssh-release deploy --json --progress
310
+ ssh-release rollback --json --progress
302
311
  ```
303
312
 
304
313
  该模式会按行输出 NDJSON。进度事件先输出,最终结果最后输出:
@@ -309,9 +318,11 @@ ssh-release deploy --json --progress
309
318
  {"ok":true,"command":"deploy","result":{"mode":"release","version":"20260625-153000","verified":true}}
310
319
  ```
311
320
 
312
- `stage` 可能是 `source`、`lock`、`package`、`publish`、`cleanup`,`status` 可能是 `start`、`success`、`fail`。失败事件会包含 `error` 字段。
321
+ `deploy` 的 `stage` 可能是 `source`、`lock`、`package`、`publish`、`cleanup`。
322
+ `rollback` 的 `stage` 可能是 `lock`、`switch`、`cleanup`、`verify`。
323
+ `status` 可能是 `start`、`success`、`fail`。失败事件会包含 `error` 字段。
313
324
 
314
- 发布结果中的 `verification` 会列出已通过的远端校验项:
325
+ 发布和回滚结果中的 `verification` 会列出已通过的远端校验项:
315
326
 
316
327
  ```json
317
328
  {"verified":true,"verification":[{"name":"发布清单","status":"pass","message":"manifest.json 已上传并校验,文件数 12"}]}
@@ -359,13 +370,16 @@ export SSH_RELEASE_PASSWORD='your-password'
359
370
  - `tar`:本地打包发布内容。
360
371
  - `ssh`:执行远端目录、解压、软链接、列表和检查命令。
361
372
  - `scp`:上传压缩包和逐文件回退上传。
362
- - `sha256sum` 或 `shasum`:发布后校验远端 `manifest.json` hash。
363
373
  - `sshpass`:仅密码登录时需要;私钥登录不需要。
364
374
 
365
375
  在 macOS 上打包时会禁用 AppleDouble 和扩展属性元数据,避免把 `._*` 文件发布到 Linux 服务器。
366
376
 
377
+ 远端需要可运行 `sha256sum` 或 `shasum`,用于校验 `manifest.json` hash。
378
+
367
379
  远端 `tar` 是可选能力,不可用时会按配置回退逐文件上传。
368
380
 
381
+ Windows、macOS、Linux 和 CI 的依赖差异见 [docs/platform-requirements.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/platform-requirements.md)。
382
+
369
383
  ## 开发命令
370
384
 
371
385
  ```bash
@@ -412,6 +426,10 @@ GitHub Actions 发布模板见 [docs/github-actions.md](https://github.com/JackE
412
426
 
413
427
  失败恢复指南见 [docs/recovery.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/recovery.md)。
414
428
 
429
+ 首次发布指南见 [docs/quick-start.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/quick-start.md)。
430
+
431
+ 平台依赖说明见 [docs/platform-requirements.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/platform-requirements.md)。
432
+
415
433
  版本变更见 [CHANGELOG.md](https://github.com/JackEngineer/ssh-release/blob/main/CHANGELOG.md)。
416
434
 
417
435
  ## 项目结构
@@ -458,6 +476,8 @@ tests/
458
476
  docs/
459
477
  ├── contracts.md
460
478
  ├── github-actions.md
479
+ ├── platform-requirements.md
480
+ ├── quick-start.md
461
481
  ├── recovery.md
462
482
  ├── release-checklist.md
463
483
  └── superpowers/specs/2026-06-25-ssh-release-design.md
@@ -480,6 +500,7 @@ docs/
480
500
  - 发布和回滚锁获取、释放和锁冲突拦截。
481
501
  - 发布 manifest 生成、上传和远端 hash 校验。
482
502
  - `doctor` 远端锁状态检查和安全清理提示。
503
+ - `doctor` 本地命令和远端 hash 检查。
483
504
  - `unlock` 显式确认路径后删除远端锁。
484
505
  - 远端 `tar` 失败后的逐文件上传回退。
485
506
  - 远程版本列表读取和当前版本标记。
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { CONFIG_FILE_NAME, loadConfigFile, writeConfigTemplate } from './config.
6
6
  import { runDoctorFromFile } from './doctor.js';
7
7
  import { listReleases } from './list.js';
8
8
  import { createDeployPlan, deploy, } from './release.js';
9
- import { createRollbackPlan, rollback } from './rollback.js';
9
+ import { createRollbackPlan, rollback, } from './rollback.js';
10
10
  import { createRemoteClient } from './ssh.js';
11
11
  import { unlock } from './unlock.js';
12
12
  export function isCliEntrypoint(moduleUrl, argvEntry) {
@@ -54,6 +54,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
54
54
  return 0;
55
55
  }
56
56
  io.log(`已创建 ${parsed.configPath}`);
57
+ printInitNextSteps(parsed.configPath, io);
57
58
  return 0;
58
59
  }
59
60
  if (command === 'deploy') {
@@ -72,7 +73,13 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
72
73
  return 0;
73
74
  }
74
75
  if (command === 'rollback') {
75
- const result = await handlers.rollback(args[0], { dryRun: parsed.dryRun });
76
+ const rollbackOptions = {
77
+ dryRun: parsed.dryRun,
78
+ };
79
+ if (parsed.json && parsed.progress) {
80
+ rollbackOptions.onProgress = (event) => printJsonProgress('rollback', event, io);
81
+ }
82
+ const result = await handlers.rollback(args[0], rollbackOptions);
76
83
  if (parsed.json) {
77
84
  printJsonResult('rollback', result, 0, io);
78
85
  return 0;
@@ -191,8 +198,8 @@ function parseCliArgs(argv) {
191
198
  if (progress && !json) {
192
199
  return createParsedError('--progress 需要配合 --json 使用', configPath, json);
193
200
  }
194
- if (progress && args[0] && args[0] !== 'deploy') {
195
- return createParsedError('--progress 仅支持 deploy 命令', configPath, json);
201
+ if (progress && args[0] && args[0] !== 'deploy' && args[0] !== 'rollback') {
202
+ return createParsedError('--progress 仅支持 deploy 或 rollback 命令', configPath, json);
196
203
  }
197
204
  return {
198
205
  args: args.slice(1),
@@ -235,7 +242,9 @@ function createDefaultHandlers(configPath) {
235
242
  if (options?.dryRun) {
236
243
  return createRollbackPlan(config, createRemoteClient(config), version);
237
244
  }
238
- return rollback(config, createRemoteClient(config), version);
245
+ return rollback(config, createRemoteClient(config), version, {
246
+ onProgress: options?.onProgress,
247
+ });
239
248
  },
240
249
  list: async () => {
241
250
  const config = await loadConfigFile(configPath);
@@ -316,6 +325,12 @@ function printRollbackResult(result, io) {
316
325
  }
317
326
  io.log(`已回滚到版本: ${result.version}`);
318
327
  io.log(`当前指向: ${result.currentSymlink}`);
328
+ if (result.verified) {
329
+ io.log('回滚校验通过');
330
+ for (const check of result.verification ?? []) {
331
+ io.log(`校验: ${check.name} - ${check.message}`);
332
+ }
333
+ }
319
334
  for (const warning of result.warnings) {
320
335
  io.log(`警告: ${warning}`);
321
336
  }
@@ -353,6 +368,13 @@ function printUnlockResult(result, io) {
353
368
  io.log('确认没有发布或回滚任务后再执行:');
354
369
  io.log(`ssh-release unlock --confirm ${result.lockPath}`);
355
370
  }
371
+ function printInitNextSteps(configPath, io) {
372
+ io.log('下一步:');
373
+ io.log('1. 设置 SSH_RELEASE_HOST、SSH_RELEASE_USER,并选择密码或私钥认证。');
374
+ io.log('2. 确认 source.path 和 target.path 指向要发布的本地目录和远端目录。');
375
+ io.log(`3. 运行 ssh-release doctor --config ${configPath} 检查配置和服务器连接。`);
376
+ io.log(`4. 运行 ssh-release deploy --plan --config ${configPath} 预览发布计划。`);
377
+ }
356
378
  function printJsonResult(command, result, exitCode, io) {
357
379
  io.log(JSON.stringify({
358
380
  ok: exitCode === 0,
@@ -402,6 +424,7 @@ function createUsageText() {
402
424
  ssh-release rollback [version] [--config <path>]
403
425
  ssh-release rollback [version] --dry-run [--config <path>]
404
426
  ssh-release rollback [version] --plan [--config <path>]
427
+ ssh-release rollback [version] --json --progress [--config <path>]
405
428
  ssh-release unlock [--confirm <lock-path>] [--config <path>]
406
429
  ssh-release <command> --json
407
430
  ssh-release --help
@@ -415,6 +438,9 @@ function formatError(error) {
415
438
  return error instanceof Error ? error.message : String(error);
416
439
  }
417
440
  function createErrorHint(command, error) {
441
+ if (error.includes('配置文件不存在')) {
442
+ return '先运行 ssh-release init 生成配置文件,再填写 source.path、server 和 target.path 后执行 ssh-release doctor。';
443
+ }
418
444
  if (error.includes('远程已有发布任务正在运行') || error.includes('.ssh-release.lock')) {
419
445
  return '先运行 ssh-release unlock 查看远端锁,确认没有发布或回滚任务后再按提示删除锁。';
420
446
  }
@@ -435,12 +461,14 @@ function createErrorHint(command, error) {
435
461
  || error.includes('manifest.json hash'))) {
436
462
  return '先运行 ssh-release list --json 和 ssh-release doctor --json,确认 current、版本目录、manifest 和远端锁状态。';
437
463
  }
464
+ if (error.includes('sshpass')) {
465
+ return '当前配置使用密码登录,本机需要安装 sshpass;macOS 可运行 brew install hudochenkov/sshpass/sshpass,Ubuntu/Debian 可运行 sudo apt-get install sshpass,Windows 和 CI 推荐改用私钥登录。';
466
+ }
438
467
  if (command
439
468
  && command !== 'init'
440
469
  && (error.includes('Permission denied')
441
470
  || error.includes('Connection timed out')
442
471
  || error.includes('Could not resolve hostname')
443
- || error.includes('sshpass')
444
472
  || error.includes('SSH'))) {
445
473
  return '先运行 ssh-release doctor 检查 SSH 连接、认证信息和远端目录权限。';
446
474
  }
package/dist/doctor.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { access } from 'node:fs/promises';
2
2
  import { loadConfigFile } from './config.js';
3
3
  import { readRemoteLockStatus } from './lock.js';
4
+ import { runProcess } from './process.js';
4
5
  import { shellQuote } from './remote.js';
5
6
  export async function runDoctorFromFile(configPath, createClient) {
6
7
  let config;
@@ -32,8 +33,25 @@ export async function runDoctor(config, client, options = {}) {
32
33
  message: '配置字段有效',
33
34
  });
34
35
  await addSourcePathCheck(checks, config.source.path);
36
+ await addLocalDependencyChecks(checks, config, options.localCommandExists ?? defaultLocalCommandExists);
37
+ if (checks.some((check) => check.status === 'fail')) {
38
+ return {
39
+ ok: false,
40
+ checks,
41
+ };
42
+ }
35
43
  await addRemoteCheck(checks, 'SSH 连接', () => client.exec('true'), 'SSH 可连接');
36
44
  await addRemoteCheck(checks, '远程目录', () => client.exec(`mkdir -p ${shellQuote(config.target.path)} && test -w ${shellQuote(config.target.path)}`), '远程目录可创建且可写');
45
+ await addRemoteCheck(checks, '远端 hash', () => client.exec([
46
+ 'if command -v sha256sum >/dev/null 2>&1; then',
47
+ 'command -v sha256sum;',
48
+ 'elif command -v shasum >/dev/null 2>&1; then',
49
+ 'command -v shasum;',
50
+ 'else',
51
+ 'echo "远端缺少 sha256sum 或 shasum" >&2;',
52
+ 'exit 1;',
53
+ 'fi',
54
+ ].join(' ')), '远端 sha256sum 或 shasum 可用');
37
55
  await addRemoteLockCheck(checks, config, client);
38
56
  try {
39
57
  await client.exec('command -v tar');
@@ -127,6 +145,35 @@ async function addSourcePathCheck(checks, sourcePath) {
127
145
  });
128
146
  }
129
147
  }
148
+ async function addLocalDependencyChecks(checks, config, localCommandExists) {
149
+ await addLocalCommandCheck(checks, 'tar', '本地 tar', localCommandExists);
150
+ await addLocalCommandCheck(checks, 'ssh', '本地 ssh', localCommandExists);
151
+ await addLocalCommandCheck(checks, 'scp', '本地 scp', localCommandExists);
152
+ if (config.server.password) {
153
+ await addLocalCommandCheck(checks, 'sshpass', '本地 sshpass', localCommandExists, '本地 sshpass 不可用;密码登录需要安装 sshpass,或改用私钥登录');
154
+ }
155
+ }
156
+ async function addLocalCommandCheck(checks, command, name, localCommandExists, missingMessage = `${name} 不可用;请安装后重试`) {
157
+ const exists = await localCommandExists(command);
158
+ checks.push({
159
+ name,
160
+ status: exists ? 'pass' : 'fail',
161
+ message: exists ? `${name} 可用` : missingMessage,
162
+ });
163
+ }
164
+ async function defaultLocalCommandExists(command) {
165
+ try {
166
+ if (process.platform === 'win32') {
167
+ await runProcess('where.exe', [command]);
168
+ return true;
169
+ }
170
+ await runProcess('sh', ['-c', `command -v ${shellQuote(command)} >/dev/null 2>&1`]);
171
+ return true;
172
+ }
173
+ catch {
174
+ return false;
175
+ }
176
+ }
130
177
  async function addRemoteCheck(checks, name, run, successMessage) {
131
178
  try {
132
179
  await run();
package/dist/rollback.js CHANGED
@@ -61,32 +61,35 @@ export async function createRollbackPlan(config, client, requestedVersion) {
61
61
  ],
62
62
  };
63
63
  }
64
- export async function rollback(config, client, requestedVersion) {
64
+ export async function rollback(config, client, requestedVersion, options = {}) {
65
65
  if (config.deploy.mode === 'overwrite') {
66
66
  throw new Error('overwrite 模式不支持回滚');
67
67
  }
68
- const releaseLock = await acquireRemoteLock(config, client, { createTargetPath: false });
68
+ const releaseLock = await withRollbackProgress(options, 'lock', () => acquireRemoteLock(config, client, { createTargetPath: false }));
69
69
  let result;
70
70
  let rollbackError;
71
+ let cleanupError;
71
72
  try {
72
- const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
73
- const currentSymlinkPath = remoteJoin(config.target.path, config.target.currentSymlink);
74
- const releases = await readRemoteReleaseNames(client, releasesPath);
75
- const currentVersion = await readCurrentVersion(client, currentSymlinkPath);
76
- if (!currentVersion) {
77
- throw new Error('当前版本不存在');
78
- }
79
- const targetVersion = selectRollbackTarget({
80
- releases,
81
- currentVersion,
82
- requestedVersion,
73
+ result = await withRollbackProgress(options, 'switch', async () => {
74
+ const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
75
+ const currentSymlinkPath = remoteJoin(config.target.path, config.target.currentSymlink);
76
+ const releases = await readRemoteReleaseNames(client, releasesPath);
77
+ const currentVersion = await readCurrentVersion(client, currentSymlinkPath);
78
+ if (!currentVersion) {
79
+ throw new Error('当前版本不存在');
80
+ }
81
+ const targetVersion = selectRollbackTarget({
82
+ releases,
83
+ currentVersion,
84
+ requestedVersion,
85
+ });
86
+ await client.exec(`ln -sfn ${shellQuote(remoteJoin(config.target.releasesDir, targetVersion))} ${shellQuote(currentSymlinkPath)}`);
87
+ return {
88
+ version: targetVersion,
89
+ currentSymlink: currentSymlinkPath,
90
+ warnings: [],
91
+ };
83
92
  });
84
- await client.exec(`ln -sfn ${shellQuote(remoteJoin(config.target.releasesDir, targetVersion))} ${shellQuote(currentSymlinkPath)}`);
85
- result = {
86
- version: targetVersion,
87
- currentSymlink: currentSymlinkPath,
88
- warnings: [],
89
- };
90
93
  return result;
91
94
  }
92
95
  catch (error) {
@@ -94,20 +97,100 @@ export async function rollback(config, client, requestedVersion) {
94
97
  throw error;
95
98
  }
96
99
  finally {
97
- try {
98
- await releaseLock();
99
- }
100
- catch (error) {
101
- const message = `远程发布锁清理失败: ${formatError(error)}`;
102
- if (result) {
103
- result.warnings.push(message);
100
+ await withRollbackProgress(options, 'cleanup', async () => {
101
+ try {
102
+ await releaseLock();
103
+ }
104
+ catch (error) {
105
+ const message = `远程回滚锁清理失败: ${formatError(error)}`;
106
+ if (result) {
107
+ result.warnings.push(message);
108
+ }
109
+ else if (!rollbackError) {
110
+ cleanupError = error;
111
+ }
104
112
  }
105
- else if (!rollbackError) {
106
- throw error;
113
+ if (cleanupError && !rollbackError) {
114
+ throw cleanupError;
107
115
  }
116
+ });
117
+ const rollbackResult = result;
118
+ if (rollbackResult && !rollbackError && !cleanupError) {
119
+ await withRollbackProgress(options, 'verify', async () => {
120
+ rollbackResult.verification = await verifyRollbackResult(config, client, rollbackResult);
121
+ rollbackResult.verified = true;
122
+ });
108
123
  }
109
124
  }
110
125
  }
126
+ async function verifyRollbackResult(config, client, result) {
127
+ return [
128
+ await verifyRemoteDirectory(client, remoteJoin(config.target.path, config.target.releasesDir, result.version), '目标版本', '目标版本目录存在'),
129
+ await verifyCurrentSymlink(client, result.currentSymlink, remoteJoin(config.target.releasesDir, result.version)),
130
+ await verifyRemoteLockReleased(config, client),
131
+ ];
132
+ }
133
+ async function verifyRemoteDirectory(client, remotePath, name, successMessage) {
134
+ try {
135
+ await client.exec(`test -d ${shellQuote(remotePath)}`);
136
+ }
137
+ catch (error) {
138
+ throw new Error(`${name}校验失败: ${formatError(error)}`);
139
+ }
140
+ return {
141
+ name,
142
+ status: 'pass',
143
+ message: successMessage,
144
+ };
145
+ }
146
+ async function verifyCurrentSymlink(client, currentSymlinkPath, expectedTarget) {
147
+ let actualTarget;
148
+ try {
149
+ const result = await client.exec(`readlink ${shellQuote(currentSymlinkPath)}`);
150
+ actualTarget = result.stdout.trim();
151
+ }
152
+ catch (error) {
153
+ throw new Error(`current 校验失败: ${formatError(error)}`);
154
+ }
155
+ if (actualTarget !== expectedTarget) {
156
+ throw new Error(`current 未指向目标版本: 期望 ${expectedTarget},实际 ${actualTarget || '空'}`);
157
+ }
158
+ return {
159
+ name: '当前版本',
160
+ status: 'pass',
161
+ message: 'current 已指向目标版本',
162
+ };
163
+ }
164
+ async function verifyRemoteLockReleased(config, client) {
165
+ const lockStatus = await readRemoteLockStatus(config, client);
166
+ if (lockStatus.locked) {
167
+ throw new Error(`回滚锁未清理: ${lockStatus.lockPath}`);
168
+ }
169
+ return {
170
+ name: '远端锁',
171
+ status: 'pass',
172
+ message: '回滚锁已清理',
173
+ };
174
+ }
175
+ async function withRollbackProgress(options, stage, run) {
176
+ await emitRollbackProgress(options, { stage, status: 'start' });
177
+ try {
178
+ const result = await run();
179
+ await emitRollbackProgress(options, { stage, status: 'success' });
180
+ return result;
181
+ }
182
+ catch (error) {
183
+ await emitRollbackProgress(options, {
184
+ stage,
185
+ status: 'fail',
186
+ error: formatError(error),
187
+ });
188
+ throw error;
189
+ }
190
+ }
191
+ async function emitRollbackProgress(options, event) {
192
+ await options.onProgress?.(event);
193
+ }
111
194
  function formatError(error) {
112
195
  return error instanceof Error ? error.message : String(error);
113
196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ssh-release",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "A general-purpose SSH file release CLI.",
5
5
  "type": "module",
6
6
  "bin": {