ssh-release 1.0.2 → 1.2.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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0 - 2026-06-26
4
+
5
+ ### Added
6
+
7
+ - 增加首次发布指南,覆盖初始化、配置、预检、发布、列表和回滚流程。
8
+ - 增加平台依赖说明,补充 macOS、Ubuntu/Debian、Windows 和 CI 中的 `sshpass`、OpenSSH、`tar`、远端 hash 命令要求。
9
+ - `ssh-release init` 成功后输出下一步操作提示。
10
+ - 配置文件缺失和 `sshpass` 缺失时输出更具体的下一步提示。
11
+ - `ssh-release doctor` 增加本地 `tar`、`ssh`、`scp`、`sshpass` 和远端 hash 命令检查。
12
+
13
+ ## 1.1.0 - 2026-06-26
14
+
15
+ ### Added
16
+
17
+ - 增强 `deploy --dry-run` 并新增 `deploy --plan`,预览上传、manifest、切换、清理和校验计划。
18
+ - 增强 `rollback --dry-run` 并新增 `rollback --plan`,预览回滚目标、`current` 切换和校验项。
19
+ - 增加常见失败的下一步提示,覆盖远端锁、回滚目标、发布校验和 SSH 连接错误。
20
+ - 增加发布 manifest,发布时生成并上传 `manifest.json`,记录文件清单、大小和 SHA-256。
21
+ - 增强发布后校验,远端 `manifest.json` 会进行 hash 校验。
22
+
3
23
  ## 1.0.2 - 2026-06-26
4
24
 
5
25
  ### Changed
package/README.md CHANGED
@@ -11,14 +11,18 @@
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
- - 发布后远端校验:确认版本目录或目标目录存在、`current` 已指向新版本、远端锁已清理。
21
+ - `deploy --plan`:不连接远端、不修改服务器,预览上传、切换、清理和校验计划。
22
+ - `rollback --plan`:连接远端读取版本状态,但不修改服务器,预览回滚切换计划。
23
+ - 失败下一步提示:常见锁、回滚目标、发布校验和 SSH 错误会提示下一步操作。
24
+ - 发布 manifest:每次发布生成 `manifest.json`,记录版本、发布时间、本地来源、文件清单、文件大小和 SHA-256。
25
+ - 发布后远端校验:确认版本目录或目标目录存在、`current` 已指向新版本、`manifest.json` hash 匹配、远端锁已清理。
22
26
  - `release` 模式:上传压缩包、远端解压、切换 `current`、清理旧版本。
23
27
  - `overwrite` 模式:直接覆盖发布到目标目录。
24
28
  - 远端 `tar` 解压失败时回退逐文件上传。
@@ -51,6 +55,9 @@ ssh-release --help
51
55
  ssh-release --version
52
56
  ```
53
57
 
58
+ 首次接入步骤见 [docs/quick-start.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/quick-start.md)。
59
+ 本机和 CI 依赖见 [docs/platform-requirements.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/platform-requirements.md)。
60
+
54
61
  ## 本地开发
55
62
 
56
63
  ```bash
@@ -173,12 +180,15 @@ source files -> package -> upload -> release -> activate -> rollback
173
180
  ssh-release doctor
174
181
  ```
175
182
 
176
- `doctor` 会检查 `.ssh-release.lock`。没有锁时正常通过;如果发现锁,会以 `warn` 显示锁路径、pid、创建时间和安全清理提示。
183
+ `doctor` 会先检查本地 `tar`、`ssh`、`scp`,使用密码登录时还会检查 `sshpass`。本地检查失败时不会继续连接远端。
184
+
185
+ `doctor` 也会检查 `.ssh-release.lock`。没有锁时正常通过;如果发现锁,会以 `warn` 显示锁路径、pid、创建时间和安全清理提示。
177
186
 
178
187
  需要先查看发布计划但不修改服务器时:
179
188
 
180
189
  ```bash
181
190
  ssh-release deploy --dry-run
191
+ ssh-release deploy --plan
182
192
  ```
183
193
 
184
194
  执行发布:
@@ -187,9 +197,30 @@ ssh-release deploy --dry-run
187
197
  ssh-release deploy
188
198
  ```
189
199
 
190
- `release` 模式发布成功后会输出版本号、版本目录和 `current` 软链接路径。只有压缩包上传和解压完成后才切换 `current`。
200
+ `deploy --dry-run` `deploy --plan` 都不会连接远端或修改服务器,会预览:
201
+
202
+ - 将上传的本地来源和远端临时压缩包路径。
203
+ - 将写入的远端 `manifest.json` 路径、文件数量和总字节数。
204
+ - `release` 模式下将切换的 `current` 目标。
205
+ - 将清理的远端临时压缩包,以及旧版本保留策略。
206
+ - 发布后会执行的远端校验项。
207
+
208
+ `release` 模式发布成功后会输出版本号、版本目录、`current` 软链接路径和远端 `manifest.json` 路径。只有压缩包上传、解压和发布清单上传完成后才切换 `current`。
209
+
210
+ 发布命令返回成功前会执行远端状态校验。`release` 模式会确认新版本目录存在、`current` 已指向新版本、发布清单 hash 匹配、远端锁已清理;`overwrite` 模式会确认目标目录存在、发布清单 hash 匹配、远端锁已清理。校验失败时发布命令返回失败,不会把结果标记为成功。
191
211
 
192
- 发布命令返回成功前会执行远端状态校验。`release` 模式会确认新版本目录存在、`current` 已指向新版本、远端锁已清理;`overwrite` 模式会确认目标目录存在、远端锁已清理。校验失败时发布命令返回失败,不会把结果标记为成功。
212
+ `manifest.json` 会写入发布目标目录:
213
+
214
+ - `release` 模式:`<target.path>/<releasesDir>/<version>/manifest.json`
215
+ - `overwrite` 模式:`<target.path>/manifest.json`
216
+
217
+ 清单内容包含:
218
+
219
+ - `version`:本次发布版本号。
220
+ - `createdAt`:发布时间。
221
+ - `source`:配置中的本地来源路径、来源类型和排除规则。
222
+ - `files`:每个文件的相对路径、字节数和 SHA-256。
223
+ - `totals`:文件数量和总字节数。
193
224
 
194
225
  如果远端已有 `.ssh-release.lock`,说明同一目标目录可能正在发布或回滚,工具会停止在修改远端状态之前。
195
226
 
@@ -227,6 +258,20 @@ ssh-release rollback
227
258
  ssh-release rollback 20260625-150000
228
259
  ```
229
260
 
261
+ 需要先查看回滚计划但不切换 `current` 时:
262
+
263
+ ```bash
264
+ ssh-release rollback [version] --dry-run
265
+ ssh-release rollback [version] --plan
266
+ ```
267
+
268
+ `rollback --dry-run` 和 `rollback --plan` 会连接远端读取锁状态、版本列表和当前版本,但不会创建锁、不会切换 `current`、不会删除任何版本目录。预览会显示:
269
+
270
+ - 当前版本和目标版本。
271
+ - 将切换的 `current` 路径和目标。
272
+ - 回滚不会删除版本目录。
273
+ - 实际回滚前需要满足的校验项。
274
+
230
275
  `overwrite` 模式没有版本列表,也不支持回滚。
231
276
 
232
277
  ## JSON 输出
@@ -235,6 +280,7 @@ ssh-release rollback 20260625-150000
235
280
 
236
281
  ```bash
237
282
  ssh-release deploy --dry-run --json
283
+ ssh-release deploy --plan --json
238
284
  ssh-release deploy --json --progress
239
285
  ssh-release doctor --json
240
286
  ssh-release list --json
@@ -245,7 +291,7 @@ ssh-release unlock --json
245
291
  成功时输出单行 JSON:
246
292
 
247
293
  ```json
248
- {"ok":true,"command":"deploy","result":{"mode":"release","version":"20260625-153000","verified":true}}
294
+ {"ok":true,"command":"deploy","result":{"mode":"release","version":"20260625-153000","manifest":{"remotePath":"/var/www/my-app/releases/20260625-153000/manifest.json","fileCount":12,"totalBytes":34567,"sha256":"..."},"verified":true}}
249
295
  ```
250
296
 
251
297
  命令执行失败时输出:
@@ -273,7 +319,7 @@ ssh-release deploy --json --progress
273
319
  发布结果中的 `verification` 会列出已通过的远端校验项:
274
320
 
275
321
  ```json
276
- {"verified":true,"verification":[{"name":"当前版本","status":"pass","message":"current 已指向新版本"}]}
322
+ {"verified":true,"verification":[{"name":"发布清单","status":"pass","message":"manifest.json 已上传并校验,文件数 12"}]}
277
323
  ```
278
324
 
279
325
  `doctor` 检查失败、`unlock` 发现锁但未删除时会返回非零退出码,并在 JSON 的 `result` 中保留检查结果。
@@ -322,8 +368,12 @@ export SSH_RELEASE_PASSWORD='your-password'
322
368
 
323
369
  在 macOS 上打包时会禁用 AppleDouble 和扩展属性元数据,避免把 `._*` 文件发布到 Linux 服务器。
324
370
 
371
+ 远端需要可运行 `sha256sum` 或 `shasum`,用于校验 `manifest.json` hash。
372
+
325
373
  远端 `tar` 是可选能力,不可用时会按配置回退逐文件上传。
326
374
 
375
+ Windows、macOS、Linux 和 CI 的依赖差异见 [docs/platform-requirements.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/platform-requirements.md)。
376
+
327
377
  ## 开发命令
328
378
 
329
379
  ```bash
@@ -370,6 +420,10 @@ GitHub Actions 发布模板见 [docs/github-actions.md](https://github.com/JackE
370
420
 
371
421
  失败恢复指南见 [docs/recovery.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/recovery.md)。
372
422
 
423
+ 首次发布指南见 [docs/quick-start.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/quick-start.md)。
424
+
425
+ 平台依赖说明见 [docs/platform-requirements.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/platform-requirements.md)。
426
+
373
427
  版本变更见 [CHANGELOG.md](https://github.com/JackEngineer/ssh-release/blob/main/CHANGELOG.md)。
374
428
 
375
429
  ## 项目结构
@@ -386,6 +440,7 @@ src/
386
440
  ├── doctor.ts
387
441
  ├── lock.ts
388
442
  ├── list.ts
443
+ ├── manifest.ts
389
444
  ├── package.ts
390
445
  ├── process.ts
391
446
  ├── remote.ts
@@ -403,6 +458,7 @@ tests/
403
458
  ├── deploy.test.ts
404
459
  ├── e2e.test.ts
405
460
  ├── list-doctor.test.ts
461
+ ├── manifest.test.ts
406
462
  ├── package-json.test.ts
407
463
  ├── package.test.ts
408
464
  ├── release.test.ts
@@ -414,6 +470,8 @@ tests/
414
470
  docs/
415
471
  ├── contracts.md
416
472
  ├── github-actions.md
473
+ ├── platform-requirements.md
474
+ ├── quick-start.md
417
475
  ├── recovery.md
418
476
  ├── release-checklist.md
419
477
  └── superpowers/specs/2026-06-25-ssh-release-design.md
@@ -434,7 +492,9 @@ docs/
434
492
  - macOS AppleDouble 元数据排除。
435
493
  - `release` 和 `overwrite` 发布流程。
436
494
  - 发布和回滚锁获取、释放和锁冲突拦截。
495
+ - 发布 manifest 生成、上传和远端 hash 校验。
437
496
  - `doctor` 远端锁状态检查和安全清理提示。
497
+ - `doctor` 本地命令和远端 hash 检查。
438
498
  - `unlock` 显式确认路径后删除远端锁。
439
499
  - 远端 `tar` 失败后的逐文件上传回退。
440
500
  - 远程版本列表读取和当前版本标记。
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 { 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,7 @@ 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]);
76
+ const result = await handlers.rollback(args[0], { dryRun: parsed.dryRun });
76
77
  if (parsed.json) {
77
78
  printJsonResult('rollback', result, 0, io);
78
79
  return 0;
@@ -121,11 +122,16 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
121
122
  return command ? 1 : 0;
122
123
  }
123
124
  catch (error) {
125
+ const errorMessage = formatError(error);
126
+ const hint = createErrorHint(command, errorMessage);
124
127
  if (parsed.json) {
125
- printJsonError(command, formatError(error), io);
128
+ printJsonError(command, errorMessage, io, hint);
126
129
  return 1;
127
130
  }
128
- io.error(error instanceof Error ? error.message : String(error));
131
+ io.error(errorMessage);
132
+ if (hint) {
133
+ io.error(`下一步: ${hint}`);
134
+ }
129
135
  return 1;
130
136
  }
131
137
  }
@@ -165,7 +171,7 @@ function parseCliArgs(argv) {
165
171
  index += 1;
166
172
  continue;
167
173
  }
168
- if (arg === '--dry-run') {
174
+ if (arg === '--dry-run' || arg === '--plan') {
169
175
  dryRun = true;
170
176
  continue;
171
177
  }
@@ -225,8 +231,11 @@ function createDefaultHandlers(configPath) {
225
231
  onProgress: options?.onProgress,
226
232
  });
227
233
  },
228
- rollback: async (version) => {
234
+ rollback: async (version, options) => {
229
235
  const config = await loadConfigFile(configPath);
236
+ if (options?.dryRun) {
237
+ return createRollbackPlan(config, createRemoteClient(config), version);
238
+ }
230
239
  return rollback(config, createRemoteClient(config), version);
231
240
  },
232
241
  list: async () => {
@@ -248,12 +257,23 @@ function printDeployResult(result, io) {
248
257
  io.log(`模式: ${result.mode}`);
249
258
  io.log(`源路径: ${result.sourcePath}`);
250
259
  io.log(`目标目录: ${result.targetPath}`);
260
+ io.log(`计划上传: ${result.upload.sourcePath} -> ${result.upload.archivePath}`);
261
+ io.log(`计划清单: ${result.upload.manifestPath}`);
262
+ io.log(`计划文件: ${result.upload.fileCount} 个,${result.upload.totalBytes} 字节`);
251
263
  if (result.version) {
252
264
  io.log(`计划版本: ${result.version}`);
253
265
  }
254
- if (result.currentSymlink) {
266
+ if (result.switch) {
267
+ io.log(`计划切换: ${result.switch.currentSymlink} -> ${result.switch.target}`);
268
+ }
269
+ else if (result.currentSymlink) {
255
270
  io.log(`计划切换: ${result.currentSymlink}`);
256
271
  }
272
+ io.log(`计划清理: ${result.cleanup.tempArchivePath}`);
273
+ io.log(`版本保留: ${result.cleanup.oldReleases}`);
274
+ for (const verification of result.verification) {
275
+ io.log(`计划校验: ${verification}`);
276
+ }
257
277
  return;
258
278
  }
259
279
  if (result.mode === 'release') {
@@ -268,6 +288,10 @@ function printDeployResult(result, io) {
268
288
  if (result.usedFallback) {
269
289
  io.log('远端 tar 不可用或解压失败,已使用逐文件上传');
270
290
  }
291
+ if (result.manifest) {
292
+ io.log(`发布清单: ${result.manifest.remotePath}`);
293
+ io.log(`清单文件数: ${result.manifest.fileCount}`);
294
+ }
271
295
  if (result.verified) {
272
296
  io.log('远端校验通过');
273
297
  for (const check of result.verification ?? []) {
@@ -279,6 +303,18 @@ function printDeployResult(result, io) {
279
303
  }
280
304
  }
281
305
  function printRollbackResult(result, io) {
306
+ if ('dryRun' in result) {
307
+ io.log('回滚预检通过,不会修改远程服务器');
308
+ io.log(`当前版本: ${result.currentVersion}`);
309
+ io.log(`目标版本: ${result.version}`);
310
+ io.log(`目标目录: ${result.targetPath}`);
311
+ io.log(`计划切换: ${result.switch.currentSymlink}: ${result.switch.from} -> ${result.switch.target}`);
312
+ io.log(`计划清理: ${result.cleanup.oldReleases}`);
313
+ for (const verification of result.verification) {
314
+ io.log(`计划校验: ${verification}`);
315
+ }
316
+ return;
317
+ }
282
318
  io.log(`已回滚到版本: ${result.version}`);
283
319
  io.log(`当前指向: ${result.currentSymlink}`);
284
320
  for (const warning of result.warnings) {
@@ -318,6 +354,13 @@ function printUnlockResult(result, io) {
318
354
  io.log('确认没有发布或回滚任务后再执行:');
319
355
  io.log(`ssh-release unlock --confirm ${result.lockPath}`);
320
356
  }
357
+ function printInitNextSteps(configPath, io) {
358
+ io.log('下一步:');
359
+ io.log('1. 设置 SSH_RELEASE_HOST、SSH_RELEASE_USER,并选择密码或私钥认证。');
360
+ io.log('2. 确认 source.path 和 target.path 指向要发布的本地目录和远端目录。');
361
+ io.log(`3. 运行 ssh-release doctor --config ${configPath} 检查配置和服务器连接。`);
362
+ io.log(`4. 运行 ssh-release deploy --plan --config ${configPath} 预览发布计划。`);
363
+ }
321
364
  function printJsonResult(command, result, exitCode, io) {
322
365
  io.log(JSON.stringify({
323
366
  ok: exitCode === 0,
@@ -333,12 +376,16 @@ function printJsonProgress(command, event, io) {
333
376
  ...event,
334
377
  }));
335
378
  }
336
- function printJsonError(command, error, io) {
337
- io.log(JSON.stringify({
379
+ function printJsonError(command, error, io, hint) {
380
+ const payload = {
338
381
  ok: false,
339
382
  command,
340
383
  error,
341
- }));
384
+ };
385
+ if (hint) {
386
+ payload.hint = hint;
387
+ }
388
+ io.log(JSON.stringify(payload));
342
389
  }
343
390
  function toRealPath(filePath) {
344
391
  try {
@@ -357,9 +404,12 @@ function createUsageText() {
357
404
  ssh-release doctor [--config <path>]
358
405
  ssh-release deploy [--config <path>]
359
406
  ssh-release deploy --dry-run [--config <path>]
407
+ ssh-release deploy --plan [--config <path>]
360
408
  ssh-release deploy --json --progress [--config <path>]
361
409
  ssh-release list [--config <path>]
362
410
  ssh-release rollback [version] [--config <path>]
411
+ ssh-release rollback [version] --dry-run [--config <path>]
412
+ ssh-release rollback [version] --plan [--config <path>]
363
413
  ssh-release unlock [--confirm <lock-path>] [--config <path>]
364
414
  ssh-release <command> --json
365
415
  ssh-release --help
@@ -372,6 +422,43 @@ function readPackageVersion() {
372
422
  function formatError(error) {
373
423
  return error instanceof Error ? error.message : String(error);
374
424
  }
425
+ function createErrorHint(command, error) {
426
+ if (error.includes('配置文件不存在')) {
427
+ return '先运行 ssh-release init 生成配置文件,再填写 source.path、server 和 target.path 后执行 ssh-release doctor。';
428
+ }
429
+ if (error.includes('远程已有发布任务正在运行') || error.includes('.ssh-release.lock')) {
430
+ return '先运行 ssh-release unlock 查看远端锁,确认没有发布或回滚任务后再按提示删除锁。';
431
+ }
432
+ if (command === 'rollback') {
433
+ if (error.includes('回滚目标不存在')
434
+ || error.includes('没有可回滚版本')
435
+ || error.includes('当前版本不存在')) {
436
+ return '先运行 ssh-release list 查看当前版本和可用版本,再选择存在的版本回滚。';
437
+ }
438
+ if (error.includes('overwrite 模式不支持回滚')) {
439
+ return 'overwrite 模式没有版本目录;需要恢复内容时请重新发布正确的本地文件。';
440
+ }
441
+ }
442
+ if (command === 'deploy' && (error.includes('current 未指向新版本')
443
+ || error.includes('版本目录校验失败')
444
+ || error.includes('目标目录校验失败')
445
+ || error.includes('发布锁未清理')
446
+ || error.includes('manifest.json hash'))) {
447
+ return '先运行 ssh-release list --json 和 ssh-release doctor --json,确认 current、版本目录、manifest 和远端锁状态。';
448
+ }
449
+ if (error.includes('sshpass')) {
450
+ return '当前配置使用密码登录,本机需要安装 sshpass;macOS 可运行 brew install hudochenkov/sshpass/sshpass,Ubuntu/Debian 可运行 sudo apt-get install sshpass,Windows 和 CI 推荐改用私钥登录。';
451
+ }
452
+ if (command
453
+ && command !== 'init'
454
+ && (error.includes('Permission denied')
455
+ || error.includes('Connection timed out')
456
+ || error.includes('Could not resolve hostname')
457
+ || error.includes('SSH'))) {
458
+ return '先运行 ssh-release doctor 检查 SSH 连接、认证信息和远端目录权限。';
459
+ }
460
+ return undefined;
461
+ }
375
462
  if (isCliEntrypoint(import.meta.url, process.argv[1])) {
376
463
  const exitCode = await runCli();
377
464
  process.exit(exitCode);
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();
@@ -0,0 +1,86 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ export async function createReleaseManifest(options) {
6
+ const sourceStat = await stat(options.sourcePath);
7
+ const files = sourceStat.isDirectory()
8
+ ? await collectDirectoryFiles(options.sourcePath, options.exclude)
9
+ : [await createManifestFileEntry(options.sourcePath, path.basename(options.sourcePath))];
10
+ const sortedFiles = files.sort((left, right) => left.path.localeCompare(right.path));
11
+ return {
12
+ version: options.version,
13
+ createdAt: options.createdAt.toISOString(),
14
+ source: {
15
+ path: options.sourcePath,
16
+ type: sourceStat.isDirectory() ? 'directory' : 'file',
17
+ exclude: options.exclude,
18
+ },
19
+ files: sortedFiles,
20
+ totals: {
21
+ files: sortedFiles.length,
22
+ bytes: sortedFiles.reduce((total, file) => total + file.size, 0),
23
+ },
24
+ };
25
+ }
26
+ export async function writeReleaseManifest(manifest) {
27
+ const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'ssh-release-manifest-'));
28
+ const localPath = path.join(tempDirectory, 'manifest.json');
29
+ const content = `${JSON.stringify(manifest, null, 2)}\n`;
30
+ await writeFile(localPath, content);
31
+ return {
32
+ localPath,
33
+ sha256: createHash('sha256').update(content).digest('hex'),
34
+ cleanup: async () => {
35
+ await rm(tempDirectory, { recursive: true, force: true });
36
+ },
37
+ };
38
+ }
39
+ async function collectDirectoryFiles(sourcePath, exclude, relativeDirectory = '') {
40
+ const entries = await readdir(path.join(sourcePath, relativeDirectory), { withFileTypes: true });
41
+ const files = [];
42
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
43
+ const relativePath = toPosixPath(path.join(relativeDirectory, entry.name));
44
+ if (isExcluded(relativePath, exclude)) {
45
+ continue;
46
+ }
47
+ const localPath = path.join(sourcePath, relativePath);
48
+ if (entry.isDirectory()) {
49
+ files.push(...await collectDirectoryFiles(sourcePath, exclude, relativePath));
50
+ continue;
51
+ }
52
+ if (entry.isFile()) {
53
+ files.push(await createManifestFileEntry(localPath, relativePath));
54
+ }
55
+ }
56
+ return files;
57
+ }
58
+ async function createManifestFileEntry(localPath, relativePath) {
59
+ const content = await readFile(localPath);
60
+ return {
61
+ path: toPosixPath(relativePath),
62
+ size: content.byteLength,
63
+ sha256: createHash('sha256').update(content).digest('hex'),
64
+ };
65
+ }
66
+ function isExcluded(relativePath, exclude) {
67
+ const normalizedPath = toPosixPath(relativePath);
68
+ const pathSegments = normalizedPath.split('/');
69
+ return exclude.some((pattern) => {
70
+ const normalizedPattern = toPosixPath(pattern);
71
+ if (!normalizedPattern.includes('/')) {
72
+ return pathSegments.includes(normalizedPattern);
73
+ }
74
+ return wildcardMatch(normalizedPath, normalizedPattern)
75
+ || wildcardMatch(`./${normalizedPath}`, normalizedPattern);
76
+ });
77
+ }
78
+ function wildcardMatch(value, pattern) {
79
+ const escapedPattern = pattern
80
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
81
+ .replaceAll('*', '[^/]*');
82
+ return new RegExp(`^${escapedPattern}$`).test(value);
83
+ }
84
+ function toPosixPath(value) {
85
+ return value.split(path.sep).join(path.posix.sep);
86
+ }
package/dist/release.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { access } from 'node:fs/promises';
2
2
  import { createReleasePackage, } from './package.js';
3
+ import { createReleaseManifest, writeReleaseManifest, } from './manifest.js';
3
4
  import { acquireRemoteLock, readRemoteLockStatus } from './lock.js';
4
5
  import { readRemoteReleaseNames, remoteJoin, shellQuote, } from './remote.js';
5
6
  export function createVersionName(date = new Date()) {
@@ -29,24 +30,46 @@ function pad(value) {
29
30
  }
30
31
  export async function deploy(config, client, options = {}) {
31
32
  await withDeployProgress(options, 'source', () => ensureSourceExists(config.source.path));
32
- const versionName = createVersionName(options.now ?? new Date());
33
+ const releaseDate = options.now ?? new Date();
34
+ const versionName = createVersionName(releaseDate);
33
35
  const packageFactory = options.createPackage ?? createReleasePackage;
34
36
  const releaseLock = await withDeployProgress(options, 'lock', () => acquireRemoteLock(config, client));
35
37
  let releasePackage;
38
+ let manifest;
39
+ let manifestFile;
36
40
  let result;
37
41
  let deployError;
38
42
  let cleanupError;
39
43
  try {
40
- releasePackage = await withDeployProgress(options, 'package', () => packageFactory({
41
- sourcePath: config.source.path,
42
- exclude: config.source.exclude,
43
- versionName,
44
- }));
44
+ releasePackage = await withDeployProgress(options, 'package', async () => {
45
+ const createdPackage = await packageFactory({
46
+ sourcePath: config.source.path,
47
+ exclude: config.source.exclude,
48
+ versionName,
49
+ });
50
+ try {
51
+ manifest = await createReleaseManifest({
52
+ version: versionName,
53
+ createdAt: releaseDate,
54
+ sourcePath: config.source.path,
55
+ exclude: config.source.exclude,
56
+ });
57
+ manifestFile = await writeReleaseManifest(manifest);
58
+ return createdPackage;
59
+ }
60
+ catch (error) {
61
+ await createdPackage.cleanup();
62
+ throw error;
63
+ }
64
+ });
65
+ if (!manifest || !manifestFile) {
66
+ throw new Error('发布清单生成失败');
67
+ }
45
68
  if (config.deploy.mode === 'overwrite') {
46
- result = await withDeployProgress(options, 'publish', () => deployOverwrite(config, client, releasePackage, versionName));
69
+ result = await withDeployProgress(options, 'publish', () => deployOverwrite(config, client, releasePackage, manifest, manifestFile, versionName));
47
70
  return result;
48
71
  }
49
- result = await withDeployProgress(options, 'publish', () => deployRelease(config, client, releasePackage, versionName));
72
+ result = await withDeployProgress(options, 'publish', () => deployRelease(config, client, releasePackage, manifest, manifestFile, versionName));
50
73
  return result;
51
74
  }
52
75
  catch (error) {
@@ -63,6 +86,14 @@ export async function deploy(config, client, options = {}) {
63
86
  cleanupError = error;
64
87
  }
65
88
  }
89
+ if (manifestFile) {
90
+ try {
91
+ await manifestFile.cleanup();
92
+ }
93
+ catch (error) {
94
+ cleanupError = error;
95
+ }
96
+ }
66
97
  try {
67
98
  await releaseLock();
68
99
  }
@@ -87,35 +118,87 @@ export async function deploy(config, client, options = {}) {
87
118
  }
88
119
  export async function createDeployPlan(config, options = {}) {
89
120
  await ensureSourceExists(config.source.path);
121
+ const releaseDate = options.now ?? new Date();
122
+ const versionName = createVersionName(releaseDate);
123
+ const tempArchivePath = remoteJoin(config.target.path, config.target.tempDir, `${versionName}.tgz`);
124
+ const lockPath = remoteJoin(config.target.path, '.ssh-release.lock');
125
+ const manifest = await createReleaseManifest({
126
+ version: versionName,
127
+ createdAt: releaseDate,
128
+ sourcePath: config.source.path,
129
+ exclude: config.source.exclude,
130
+ });
90
131
  if (config.deploy.mode === 'overwrite') {
91
132
  return {
92
133
  dryRun: true,
93
134
  mode: 'overwrite',
94
135
  sourcePath: config.source.path,
95
136
  targetPath: config.target.path,
137
+ upload: createDeployPlanUpload(config, manifest, tempArchivePath, remoteJoin(config.target.path, 'manifest.json')),
138
+ cleanup: {
139
+ lockPath,
140
+ tempArchivePath,
141
+ keepReleases: config.deploy.keepReleases,
142
+ oldReleases: 'overwrite 模式不清理版本目录',
143
+ },
144
+ verification: [
145
+ '目标目录存在',
146
+ 'manifest.json hash 匹配',
147
+ '远端锁已清理',
148
+ ],
96
149
  };
97
150
  }
98
- const versionName = createVersionName(options.now ?? new Date());
99
151
  const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
152
+ const releasePath = remoteJoin(releasesPath, versionName);
153
+ const currentSymlink = remoteJoin(config.target.path, config.target.currentSymlink);
154
+ const switchTarget = remoteJoin(config.target.releasesDir, versionName);
100
155
  return {
101
156
  dryRun: true,
102
157
  mode: 'release',
103
158
  version: versionName,
104
159
  sourcePath: config.source.path,
105
- targetPath: remoteJoin(releasesPath, versionName),
106
- currentSymlink: remoteJoin(config.target.path, config.target.currentSymlink),
160
+ targetPath: releasePath,
161
+ currentSymlink,
162
+ upload: createDeployPlanUpload(config, manifest, tempArchivePath, remoteJoin(releasePath, 'manifest.json')),
163
+ switch: {
164
+ currentSymlink,
165
+ target: switchTarget,
166
+ },
167
+ cleanup: {
168
+ lockPath,
169
+ tempArchivePath,
170
+ keepReleases: config.deploy.keepReleases,
171
+ oldReleases: `发布成功后保留最新 ${config.deploy.keepReleases} 个版本,并保留当前版本`,
172
+ },
173
+ verification: [
174
+ '版本目录存在',
175
+ 'current 指向新版本',
176
+ 'manifest.json hash 匹配',
177
+ '远端锁已清理',
178
+ ],
107
179
  };
108
180
  }
109
- async function deployRelease(config, client, releasePackage, versionName) {
181
+ function createDeployPlanUpload(config, manifest, archivePath, manifestPath) {
182
+ return {
183
+ sourcePath: config.source.path,
184
+ archivePath,
185
+ manifestPath,
186
+ fileCount: manifest.totals.files,
187
+ totalBytes: manifest.totals.bytes,
188
+ };
189
+ }
190
+ async function deployRelease(config, client, releasePackage, manifest, manifestFile, versionName) {
110
191
  const warnings = [];
111
192
  const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
112
193
  const releasePath = remoteJoin(releasesPath, versionName);
113
194
  const tempPath = remoteJoin(config.target.path, config.target.tempDir);
114
195
  const remoteArchivePath = remoteJoin(tempPath, `${versionName}.tgz`);
196
+ const remoteManifestPath = remoteJoin(releasePath, 'manifest.json');
115
197
  const currentSymlinkPath = remoteJoin(config.target.path, config.target.currentSymlink);
116
198
  await client.exec(`mkdir -p ${shellQuote(releasesPath)} ${shellQuote(tempPath)} ${shellQuote(releasePath)}`);
117
199
  await client.uploadFile(releasePackage.archivePath, remoteArchivePath);
118
200
  const usedFallback = await publishPackageToRemotePath(config, client, remoteArchivePath, releasePath, true);
201
+ await client.uploadFile(manifestFile.localPath, remoteManifestPath);
119
202
  await client.exec(`ln -sfn ${shellQuote(remoteJoin(config.target.releasesDir, versionName))} ${shellQuote(currentSymlinkPath)}`);
120
203
  await cleanupRemoteArchive(client, remoteArchivePath, warnings);
121
204
  await cleanupOldReleases(config, client, releasesPath, versionName, warnings);
@@ -125,24 +208,36 @@ async function deployRelease(config, client, releasePackage, versionName) {
125
208
  targetPath: releasePath,
126
209
  currentSymlink: currentSymlinkPath,
127
210
  usedFallback,
211
+ manifest: createDeployManifestSummary(manifest, manifestFile, remoteManifestPath),
128
212
  warnings,
129
213
  };
130
214
  }
131
- async function deployOverwrite(config, client, releasePackage, versionName) {
215
+ async function deployOverwrite(config, client, releasePackage, manifest, manifestFile, versionName) {
132
216
  const warnings = [];
133
217
  const tempPath = remoteJoin(config.target.path, config.target.tempDir);
134
218
  const remoteArchivePath = remoteJoin(tempPath, `${versionName}.tgz`);
219
+ const remoteManifestPath = remoteJoin(config.target.path, 'manifest.json');
135
220
  await client.exec(`mkdir -p ${shellQuote(config.target.path)} ${shellQuote(tempPath)}`);
136
221
  await client.uploadFile(releasePackage.archivePath, remoteArchivePath);
137
222
  const usedFallback = await publishPackageToRemotePath(config, client, remoteArchivePath, config.target.path, false);
223
+ await client.uploadFile(manifestFile.localPath, remoteManifestPath);
138
224
  await cleanupRemoteArchive(client, remoteArchivePath, warnings);
139
225
  return {
140
226
  mode: 'overwrite',
141
227
  targetPath: config.target.path,
142
228
  usedFallback,
229
+ manifest: createDeployManifestSummary(manifest, manifestFile, remoteManifestPath),
143
230
  warnings,
144
231
  };
145
232
  }
233
+ function createDeployManifestSummary(manifest, manifestFile, remotePath) {
234
+ return {
235
+ remotePath,
236
+ fileCount: manifest.totals.files,
237
+ totalBytes: manifest.totals.bytes,
238
+ sha256: manifestFile.sha256,
239
+ };
240
+ }
146
241
  async function publishPackageToRemotePath(config, client, remoteArchivePath, remoteTargetPath, cleanBeforeFallback) {
147
242
  if (!config.deploy.preferTar) {
148
243
  await client.uploadDirectory(config.source.path, remoteTargetPath, config.source.exclude);
@@ -190,6 +285,7 @@ async function verifyDeployResult(config, client, result) {
190
285
  if (result.mode === 'overwrite') {
191
286
  return [
192
287
  await verifyRemoteDirectory(client, result.targetPath, '目标目录', '远端目标目录存在'),
288
+ ...await verifyOptionalManifest(client, result),
193
289
  await verifyRemoteLockReleased(config, client),
194
290
  ];
195
291
  }
@@ -199,9 +295,42 @@ async function verifyDeployResult(config, client, result) {
199
295
  return [
200
296
  await verifyRemoteDirectory(client, result.targetPath, '版本目录', '远端版本目录存在'),
201
297
  await verifyCurrentSymlink(client, result.currentSymlink, remoteJoin(config.target.releasesDir, result.version)),
298
+ ...await verifyOptionalManifest(client, result),
202
299
  await verifyRemoteLockReleased(config, client),
203
300
  ];
204
301
  }
302
+ async function verifyOptionalManifest(client, result) {
303
+ if (!result.manifest) {
304
+ return [];
305
+ }
306
+ return [await verifyRemoteManifest(client, result.manifest)];
307
+ }
308
+ async function verifyRemoteManifest(client, manifest) {
309
+ try {
310
+ await client.exec(`test -f ${shellQuote(manifest.remotePath)}`);
311
+ }
312
+ catch (error) {
313
+ throw new Error(`manifest 校验失败: ${formatError(error)}`);
314
+ }
315
+ const actualHash = await readRemoteFileSha256(client, manifest.remotePath);
316
+ if (actualHash !== manifest.sha256) {
317
+ throw new Error(`manifest hash 不匹配: 期望 ${manifest.sha256},实际 ${actualHash || '空'}`);
318
+ }
319
+ return {
320
+ name: '发布清单',
321
+ status: 'pass',
322
+ message: `manifest.json 已上传并校验,文件数 ${manifest.fileCount}`,
323
+ };
324
+ }
325
+ async function readRemoteFileSha256(client, remotePath) {
326
+ const quotedPath = shellQuote(remotePath);
327
+ const result = await client.exec(`if command -v sha256sum >/dev/null 2>&1; then sha256sum ${quotedPath} | awk '{print $1}'; elif command -v shasum >/dev/null 2>&1; then shasum -a 256 ${quotedPath} | awk '{print $1}'; else echo ''; fi`);
328
+ const hash = result.stdout.trim();
329
+ if (!hash) {
330
+ throw new Error('远端缺少 sha256sum 或 shasum,无法校验 manifest hash');
331
+ }
332
+ return hash;
333
+ }
205
334
  async function verifyRemoteDirectory(client, remotePath, name, successMessage) {
206
335
  try {
207
336
  await client.exec(`test -d ${shellQuote(remotePath)}`);
package/dist/rollback.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readCurrentVersion, readRemoteReleaseNames, remoteJoin, shellQuote, } from './remote.js';
2
- import { acquireRemoteLock } from './lock.js';
2
+ import { acquireRemoteLock, readRemoteLockStatus } from './lock.js';
3
3
  export function selectRollbackTarget(selection) {
4
4
  const releases = [...new Set(selection.releases)].sort();
5
5
  if (selection.requestedVersion) {
@@ -17,6 +17,50 @@ export function selectRollbackTarget(selection) {
17
17
  }
18
18
  return releases[currentIndex - 1];
19
19
  }
20
+ export async function createRollbackPlan(config, client, requestedVersion) {
21
+ if (config.deploy.mode === 'overwrite') {
22
+ throw new Error('overwrite 模式不支持回滚');
23
+ }
24
+ const lockStatus = await readRemoteLockStatus(config, client);
25
+ if (lockStatus.locked) {
26
+ throw new Error(`远程已有发布任务正在运行,请确认后删除 ${lockStatus.lockPath}`);
27
+ }
28
+ const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
29
+ const currentSymlinkPath = remoteJoin(config.target.path, config.target.currentSymlink);
30
+ const releases = await readRemoteReleaseNames(client, releasesPath);
31
+ const currentVersion = await readCurrentVersion(client, currentSymlinkPath);
32
+ if (!currentVersion) {
33
+ throw new Error('当前版本不存在');
34
+ }
35
+ const targetVersion = selectRollbackTarget({
36
+ releases,
37
+ currentVersion,
38
+ requestedVersion,
39
+ });
40
+ return {
41
+ dryRun: true,
42
+ mode: 'release',
43
+ version: targetVersion,
44
+ requestedVersion,
45
+ currentVersion,
46
+ targetPath: remoteJoin(releasesPath, targetVersion),
47
+ currentSymlink: currentSymlinkPath,
48
+ switch: {
49
+ currentSymlink: currentSymlinkPath,
50
+ from: remoteJoin(config.target.releasesDir, currentVersion),
51
+ target: remoteJoin(config.target.releasesDir, targetVersion),
52
+ },
53
+ cleanup: {
54
+ lockPath: lockStatus.lockPath,
55
+ oldReleases: '回滚只切换 current,不删除任何版本目录',
56
+ },
57
+ verification: [
58
+ '远端锁未占用',
59
+ '目标版本目录存在',
60
+ 'current 将指向目标版本',
61
+ ],
62
+ };
63
+ }
20
64
  export async function rollback(config, client, requestedVersion) {
21
65
  if (config.deploy.mode === 'overwrite') {
22
66
  throw new Error('overwrite 模式不支持回滚');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ssh-release",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "A general-purpose SSH file release CLI.",
5
5
  "type": "module",
6
6
  "bin": {