ssh-release 1.0.2 → 1.1.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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 - 2026-06-26
4
+
5
+ ### Added
6
+
7
+ - 增强 `deploy --dry-run` 并新增 `deploy --plan`,预览上传、manifest、切换、清理和校验计划。
8
+ - 增强 `rollback --dry-run` 并新增 `rollback --plan`,预览回滚目标、`current` 切换和校验项。
9
+ - 增加常见失败的下一步提示,覆盖远端锁、回滚目标、发布校验和 SSH 连接错误。
10
+ - 增加发布 manifest,发布时生成并上传 `manifest.json`,记录文件清单、大小和 SHA-256。
11
+ - 增强发布后校验,远端 `manifest.json` 会进行 hash 校验。
12
+
3
13
  ## 1.0.2 - 2026-06-26
4
14
 
5
15
  ### Changed
package/README.md CHANGED
@@ -18,7 +18,11 @@
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` 解压失败时回退逐文件上传。
@@ -179,6 +183,7 @@ ssh-release doctor
179
183
 
180
184
  ```bash
181
185
  ssh-release deploy --dry-run
186
+ ssh-release deploy --plan
182
187
  ```
183
188
 
184
189
  执行发布:
@@ -187,9 +192,30 @@ ssh-release deploy --dry-run
187
192
  ssh-release deploy
188
193
  ```
189
194
 
190
- `release` 模式发布成功后会输出版本号、版本目录和 `current` 软链接路径。只有压缩包上传和解压完成后才切换 `current`。
195
+ `deploy --dry-run` `deploy --plan` 都不会连接远端或修改服务器,会预览:
191
196
 
192
- 发布命令返回成功前会执行远端状态校验。`release` 模式会确认新版本目录存在、`current` 已指向新版本、远端锁已清理;`overwrite` 模式会确认目标目录存在、远端锁已清理。校验失败时发布命令返回失败,不会把结果标记为成功。
197
+ - 将上传的本地来源和远端临时压缩包路径。
198
+ - 将写入的远端 `manifest.json` 路径、文件数量和总字节数。
199
+ - `release` 模式下将切换的 `current` 目标。
200
+ - 将清理的远端临时压缩包,以及旧版本保留策略。
201
+ - 发布后会执行的远端校验项。
202
+
203
+ `release` 模式发布成功后会输出版本号、版本目录、`current` 软链接路径和远端 `manifest.json` 路径。只有压缩包上传、解压和发布清单上传完成后才切换 `current`。
204
+
205
+ 发布命令返回成功前会执行远端状态校验。`release` 模式会确认新版本目录存在、`current` 已指向新版本、发布清单 hash 匹配、远端锁已清理;`overwrite` 模式会确认目标目录存在、发布清单 hash 匹配、远端锁已清理。校验失败时发布命令返回失败,不会把结果标记为成功。
206
+
207
+ `manifest.json` 会写入发布目标目录:
208
+
209
+ - `release` 模式:`<target.path>/<releasesDir>/<version>/manifest.json`
210
+ - `overwrite` 模式:`<target.path>/manifest.json`
211
+
212
+ 清单内容包含:
213
+
214
+ - `version`:本次发布版本号。
215
+ - `createdAt`:发布时间。
216
+ - `source`:配置中的本地来源路径、来源类型和排除规则。
217
+ - `files`:每个文件的相对路径、字节数和 SHA-256。
218
+ - `totals`:文件数量和总字节数。
193
219
 
194
220
  如果远端已有 `.ssh-release.lock`,说明同一目标目录可能正在发布或回滚,工具会停止在修改远端状态之前。
195
221
 
@@ -227,6 +253,20 @@ ssh-release rollback
227
253
  ssh-release rollback 20260625-150000
228
254
  ```
229
255
 
256
+ 需要先查看回滚计划但不切换 `current` 时:
257
+
258
+ ```bash
259
+ ssh-release rollback [version] --dry-run
260
+ ssh-release rollback [version] --plan
261
+ ```
262
+
263
+ `rollback --dry-run` 和 `rollback --plan` 会连接远端读取锁状态、版本列表和当前版本,但不会创建锁、不会切换 `current`、不会删除任何版本目录。预览会显示:
264
+
265
+ - 当前版本和目标版本。
266
+ - 将切换的 `current` 路径和目标。
267
+ - 回滚不会删除版本目录。
268
+ - 实际回滚前需要满足的校验项。
269
+
230
270
  `overwrite` 模式没有版本列表,也不支持回滚。
231
271
 
232
272
  ## JSON 输出
@@ -235,6 +275,7 @@ ssh-release rollback 20260625-150000
235
275
 
236
276
  ```bash
237
277
  ssh-release deploy --dry-run --json
278
+ ssh-release deploy --plan --json
238
279
  ssh-release deploy --json --progress
239
280
  ssh-release doctor --json
240
281
  ssh-release list --json
@@ -245,7 +286,7 @@ ssh-release unlock --json
245
286
  成功时输出单行 JSON:
246
287
 
247
288
  ```json
248
- {"ok":true,"command":"deploy","result":{"mode":"release","version":"20260625-153000","verified":true}}
289
+ {"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
290
  ```
250
291
 
251
292
  命令执行失败时输出:
@@ -273,7 +314,7 @@ ssh-release deploy --json --progress
273
314
  发布结果中的 `verification` 会列出已通过的远端校验项:
274
315
 
275
316
  ```json
276
- {"verified":true,"verification":[{"name":"当前版本","status":"pass","message":"current 已指向新版本"}]}
317
+ {"verified":true,"verification":[{"name":"发布清单","status":"pass","message":"manifest.json 已上传并校验,文件数 12"}]}
277
318
  ```
278
319
 
279
320
  `doctor` 检查失败、`unlock` 发现锁但未删除时会返回非零退出码,并在 JSON 的 `result` 中保留检查结果。
@@ -318,6 +359,7 @@ export SSH_RELEASE_PASSWORD='your-password'
318
359
  - `tar`:本地打包发布内容。
319
360
  - `ssh`:执行远端目录、解压、软链接、列表和检查命令。
320
361
  - `scp`:上传压缩包和逐文件回退上传。
362
+ - `sha256sum` 或 `shasum`:发布后校验远端 `manifest.json` hash。
321
363
  - `sshpass`:仅密码登录时需要;私钥登录不需要。
322
364
 
323
365
  在 macOS 上打包时会禁用 AppleDouble 和扩展属性元数据,避免把 `._*` 文件发布到 Linux 服务器。
@@ -386,6 +428,7 @@ src/
386
428
  ├── doctor.ts
387
429
  ├── lock.ts
388
430
  ├── list.ts
431
+ ├── manifest.ts
389
432
  ├── package.ts
390
433
  ├── process.ts
391
434
  ├── remote.ts
@@ -403,6 +446,7 @@ tests/
403
446
  ├── deploy.test.ts
404
447
  ├── e2e.test.ts
405
448
  ├── list-doctor.test.ts
449
+ ├── manifest.test.ts
406
450
  ├── package-json.test.ts
407
451
  ├── package.test.ts
408
452
  ├── release.test.ts
@@ -434,6 +478,7 @@ docs/
434
478
  - macOS AppleDouble 元数据排除。
435
479
  - `release` 和 `overwrite` 发布流程。
436
480
  - 发布和回滚锁获取、释放和锁冲突拦截。
481
+ - 发布 manifest 生成、上传和远端 hash 校验。
437
482
  - `doctor` 远端锁状态检查和安全清理提示。
438
483
  - `unlock` 显式确认路径后删除远端锁。
439
484
  - 远端 `tar` 失败后的逐文件上传回退。
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) {
@@ -72,7 +72,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
72
72
  return 0;
73
73
  }
74
74
  if (command === 'rollback') {
75
- const result = await handlers.rollback(args[0]);
75
+ const result = await handlers.rollback(args[0], { dryRun: parsed.dryRun });
76
76
  if (parsed.json) {
77
77
  printJsonResult('rollback', result, 0, io);
78
78
  return 0;
@@ -121,11 +121,16 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
121
121
  return command ? 1 : 0;
122
122
  }
123
123
  catch (error) {
124
+ const errorMessage = formatError(error);
125
+ const hint = createErrorHint(command, errorMessage);
124
126
  if (parsed.json) {
125
- printJsonError(command, formatError(error), io);
127
+ printJsonError(command, errorMessage, io, hint);
126
128
  return 1;
127
129
  }
128
- io.error(error instanceof Error ? error.message : String(error));
130
+ io.error(errorMessage);
131
+ if (hint) {
132
+ io.error(`下一步: ${hint}`);
133
+ }
129
134
  return 1;
130
135
  }
131
136
  }
@@ -165,7 +170,7 @@ function parseCliArgs(argv) {
165
170
  index += 1;
166
171
  continue;
167
172
  }
168
- if (arg === '--dry-run') {
173
+ if (arg === '--dry-run' || arg === '--plan') {
169
174
  dryRun = true;
170
175
  continue;
171
176
  }
@@ -225,8 +230,11 @@ function createDefaultHandlers(configPath) {
225
230
  onProgress: options?.onProgress,
226
231
  });
227
232
  },
228
- rollback: async (version) => {
233
+ rollback: async (version, options) => {
229
234
  const config = await loadConfigFile(configPath);
235
+ if (options?.dryRun) {
236
+ return createRollbackPlan(config, createRemoteClient(config), version);
237
+ }
230
238
  return rollback(config, createRemoteClient(config), version);
231
239
  },
232
240
  list: async () => {
@@ -248,12 +256,23 @@ function printDeployResult(result, io) {
248
256
  io.log(`模式: ${result.mode}`);
249
257
  io.log(`源路径: ${result.sourcePath}`);
250
258
  io.log(`目标目录: ${result.targetPath}`);
259
+ io.log(`计划上传: ${result.upload.sourcePath} -> ${result.upload.archivePath}`);
260
+ io.log(`计划清单: ${result.upload.manifestPath}`);
261
+ io.log(`计划文件: ${result.upload.fileCount} 个,${result.upload.totalBytes} 字节`);
251
262
  if (result.version) {
252
263
  io.log(`计划版本: ${result.version}`);
253
264
  }
254
- if (result.currentSymlink) {
265
+ if (result.switch) {
266
+ io.log(`计划切换: ${result.switch.currentSymlink} -> ${result.switch.target}`);
267
+ }
268
+ else if (result.currentSymlink) {
255
269
  io.log(`计划切换: ${result.currentSymlink}`);
256
270
  }
271
+ io.log(`计划清理: ${result.cleanup.tempArchivePath}`);
272
+ io.log(`版本保留: ${result.cleanup.oldReleases}`);
273
+ for (const verification of result.verification) {
274
+ io.log(`计划校验: ${verification}`);
275
+ }
257
276
  return;
258
277
  }
259
278
  if (result.mode === 'release') {
@@ -268,6 +287,10 @@ function printDeployResult(result, io) {
268
287
  if (result.usedFallback) {
269
288
  io.log('远端 tar 不可用或解压失败,已使用逐文件上传');
270
289
  }
290
+ if (result.manifest) {
291
+ io.log(`发布清单: ${result.manifest.remotePath}`);
292
+ io.log(`清单文件数: ${result.manifest.fileCount}`);
293
+ }
271
294
  if (result.verified) {
272
295
  io.log('远端校验通过');
273
296
  for (const check of result.verification ?? []) {
@@ -279,6 +302,18 @@ function printDeployResult(result, io) {
279
302
  }
280
303
  }
281
304
  function printRollbackResult(result, io) {
305
+ if ('dryRun' in result) {
306
+ io.log('回滚预检通过,不会修改远程服务器');
307
+ io.log(`当前版本: ${result.currentVersion}`);
308
+ io.log(`目标版本: ${result.version}`);
309
+ io.log(`目标目录: ${result.targetPath}`);
310
+ io.log(`计划切换: ${result.switch.currentSymlink}: ${result.switch.from} -> ${result.switch.target}`);
311
+ io.log(`计划清理: ${result.cleanup.oldReleases}`);
312
+ for (const verification of result.verification) {
313
+ io.log(`计划校验: ${verification}`);
314
+ }
315
+ return;
316
+ }
282
317
  io.log(`已回滚到版本: ${result.version}`);
283
318
  io.log(`当前指向: ${result.currentSymlink}`);
284
319
  for (const warning of result.warnings) {
@@ -333,12 +368,16 @@ function printJsonProgress(command, event, io) {
333
368
  ...event,
334
369
  }));
335
370
  }
336
- function printJsonError(command, error, io) {
337
- io.log(JSON.stringify({
371
+ function printJsonError(command, error, io, hint) {
372
+ const payload = {
338
373
  ok: false,
339
374
  command,
340
375
  error,
341
- }));
376
+ };
377
+ if (hint) {
378
+ payload.hint = hint;
379
+ }
380
+ io.log(JSON.stringify(payload));
342
381
  }
343
382
  function toRealPath(filePath) {
344
383
  try {
@@ -357,9 +396,12 @@ function createUsageText() {
357
396
  ssh-release doctor [--config <path>]
358
397
  ssh-release deploy [--config <path>]
359
398
  ssh-release deploy --dry-run [--config <path>]
399
+ ssh-release deploy --plan [--config <path>]
360
400
  ssh-release deploy --json --progress [--config <path>]
361
401
  ssh-release list [--config <path>]
362
402
  ssh-release rollback [version] [--config <path>]
403
+ ssh-release rollback [version] --dry-run [--config <path>]
404
+ ssh-release rollback [version] --plan [--config <path>]
363
405
  ssh-release unlock [--confirm <lock-path>] [--config <path>]
364
406
  ssh-release <command> --json
365
407
  ssh-release --help
@@ -372,6 +414,38 @@ function readPackageVersion() {
372
414
  function formatError(error) {
373
415
  return error instanceof Error ? error.message : String(error);
374
416
  }
417
+ function createErrorHint(command, error) {
418
+ if (error.includes('远程已有发布任务正在运行') || error.includes('.ssh-release.lock')) {
419
+ return '先运行 ssh-release unlock 查看远端锁,确认没有发布或回滚任务后再按提示删除锁。';
420
+ }
421
+ if (command === 'rollback') {
422
+ if (error.includes('回滚目标不存在')
423
+ || error.includes('没有可回滚版本')
424
+ || error.includes('当前版本不存在')) {
425
+ return '先运行 ssh-release list 查看当前版本和可用版本,再选择存在的版本回滚。';
426
+ }
427
+ if (error.includes('overwrite 模式不支持回滚')) {
428
+ return 'overwrite 模式没有版本目录;需要恢复内容时请重新发布正确的本地文件。';
429
+ }
430
+ }
431
+ if (command === 'deploy' && (error.includes('current 未指向新版本')
432
+ || error.includes('版本目录校验失败')
433
+ || error.includes('目标目录校验失败')
434
+ || error.includes('发布锁未清理')
435
+ || error.includes('manifest.json hash'))) {
436
+ return '先运行 ssh-release list --json 和 ssh-release doctor --json,确认 current、版本目录、manifest 和远端锁状态。';
437
+ }
438
+ if (command
439
+ && command !== 'init'
440
+ && (error.includes('Permission denied')
441
+ || error.includes('Connection timed out')
442
+ || error.includes('Could not resolve hostname')
443
+ || error.includes('sshpass')
444
+ || error.includes('SSH'))) {
445
+ return '先运行 ssh-release doctor 检查 SSH 连接、认证信息和远端目录权限。';
446
+ }
447
+ return undefined;
448
+ }
375
449
  if (isCliEntrypoint(import.meta.url, process.argv[1])) {
376
450
  const exitCode = await runCli();
377
451
  process.exit(exitCode);
@@ -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.1.0",
4
4
  "description": "A general-purpose SSH file release CLI.",
5
5
  "type": "module",
6
6
  "bin": {