ssh-release 0.5.0 → 0.7.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 - 2026-06-25
4
+
5
+ ### Added
6
+
7
+ - 增加发布后远端状态校验,`deploy` 成功后会确认目标目录、`current` 指向和远端锁清理状态,并在结果中输出 `verified` 与 `verification`。
8
+
9
+ ## 0.6.0 - 2026-06-25
10
+
11
+ ### Added
12
+
13
+ - 增加 `ssh-release deploy --json --progress`,发布时按 NDJSON 输出 `source`、`lock`、`package`、`publish`、`cleanup` 阶段状态,并在最后输出发布结果。
14
+
3
15
  ## 0.5.0 - 2026-06-25
4
16
 
5
17
  ### Added
package/README.md CHANGED
@@ -17,6 +17,8 @@
17
17
  - `ssh-release rollback [version]`:回滚到上一个版本或指定版本。
18
18
  - `ssh-release unlock`:查看远端锁,并在显式确认锁路径后删除锁。
19
19
  - `--json`:输出单行 JSON,便于 CI/CD 解析。
20
+ - `deploy --json --progress`:发布时输出 NDJSON 阶段进度,便于 CI/CD 展示实时状态。
21
+ - 发布后远端校验:确认版本目录或目标目录存在、`current` 已指向新版本、远端锁已清理。
20
22
  - `release` 模式:上传压缩包、远端解压、切换 `current`、清理旧版本。
21
23
  - `overwrite` 模式:直接覆盖发布到目标目录。
22
24
  - 远端 `tar` 解压失败时回退逐文件上传。
@@ -187,6 +189,8 @@ ssh-release deploy
187
189
 
188
190
  `release` 模式发布成功后会输出版本号、版本目录和 `current` 软链接路径。只有压缩包上传和解压完成后才切换 `current`。
189
191
 
192
+ 发布命令返回成功前会执行远端状态校验。`release` 模式会确认新版本目录存在、`current` 已指向新版本、远端锁已清理;`overwrite` 模式会确认目标目录存在、远端锁已清理。校验失败时发布命令返回失败,不会把结果标记为成功。
193
+
190
194
  如果远端已有 `.ssh-release.lock`,说明同一目标目录可能正在发布或回滚,工具会停止在修改远端状态之前。
191
195
 
192
196
  查看锁状态:
@@ -231,6 +235,7 @@ ssh-release rollback 20260625-150000
231
235
 
232
236
  ```bash
233
237
  ssh-release deploy --dry-run --json
238
+ ssh-release deploy --json --progress
234
239
  ssh-release doctor --json
235
240
  ssh-release list --json
236
241
  ssh-release rollback --json
@@ -240,7 +245,7 @@ ssh-release unlock --json
240
245
  成功时输出单行 JSON:
241
246
 
242
247
  ```json
243
- {"ok":true,"command":"deploy","result":{"dryRun":true,"mode":"release"}}
248
+ {"ok":true,"command":"deploy","result":{"mode":"release","version":"20260625-153000","verified":true}}
244
249
  ```
245
250
 
246
251
  命令执行失败时输出:
@@ -249,6 +254,28 @@ ssh-release unlock --json
249
254
  {"ok":false,"command":"deploy","error":"错误信息"}
250
255
  ```
251
256
 
257
+ 发布时需要持续读取阶段状态,可以使用:
258
+
259
+ ```bash
260
+ ssh-release deploy --json --progress
261
+ ```
262
+
263
+ 该模式会按行输出 NDJSON。进度事件先输出,最终结果最后输出:
264
+
265
+ ```json
266
+ {"ok":true,"command":"deploy","event":"progress","stage":"package","status":"start"}
267
+ {"ok":true,"command":"deploy","event":"progress","stage":"package","status":"success"}
268
+ {"ok":true,"command":"deploy","result":{"mode":"release","version":"20260625-153000","verified":true}}
269
+ ```
270
+
271
+ `stage` 可能是 `source`、`lock`、`package`、`publish`、`cleanup`,`status` 可能是 `start`、`success`、`fail`。失败事件会包含 `error` 字段。
272
+
273
+ 发布结果中的 `verification` 会列出已通过的远端校验项:
274
+
275
+ ```json
276
+ {"verified":true,"verification":[{"name":"当前版本","status":"pass","message":"current 已指向新版本"}]}
277
+ ```
278
+
252
279
  `doctor` 检查失败、`unlock` 发现锁但未删除时会返回非零退出码,并在 JSON 的 `result` 中保留检查结果。
253
280
 
254
281
  ## 安全边界
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  import { CONFIG_FILE_NAME, loadConfigFile, writeConfigTemplate } from './config.js';
6
6
  import { runDoctorFromFile } from './doctor.js';
7
7
  import { listReleases } from './list.js';
8
- import { createDeployPlan, deploy } from './release.js';
8
+ import { createDeployPlan, deploy, } from './release.js';
9
9
  import { rollback } from './rollback.js';
10
10
  import { createRemoteClient } from './ssh.js';
11
11
  import { unlock } from './unlock.js';
@@ -57,7 +57,13 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
57
57
  return 0;
58
58
  }
59
59
  if (command === 'deploy') {
60
- const result = await handlers.deploy({ dryRun: parsed.dryRun });
60
+ const deployOptions = {
61
+ dryRun: parsed.dryRun,
62
+ };
63
+ if (parsed.json && parsed.progress) {
64
+ deployOptions.onProgress = (event) => printJsonProgress('deploy', event, io);
65
+ }
66
+ const result = await handlers.deploy(deployOptions);
61
67
  if (parsed.json) {
62
68
  printJsonResult('deploy', result, 0, io);
63
69
  return 0;
@@ -130,6 +136,7 @@ function parseCliArgs(argv) {
130
136
  let dryRun = false;
131
137
  let help = false;
132
138
  let json = false;
139
+ let progress = false;
133
140
  let version = false;
134
141
  for (let index = 0; index < argv.length; index += 1) {
135
142
  const arg = argv[index];
@@ -145,6 +152,10 @@ function parseCliArgs(argv) {
145
152
  json = true;
146
153
  continue;
147
154
  }
155
+ if (arg === '--progress') {
156
+ progress = true;
157
+ continue;
158
+ }
148
159
  if (arg === '--config' || arg === '-c') {
149
160
  const value = argv[index + 1];
150
161
  if (!value || value.startsWith('-')) {
@@ -172,6 +183,12 @@ function parseCliArgs(argv) {
172
183
  }
173
184
  args.push(arg);
174
185
  }
186
+ if (progress && !json) {
187
+ return createParsedError('--progress 需要配合 --json 使用', configPath, json);
188
+ }
189
+ if (progress && args[0] && args[0] !== 'deploy') {
190
+ return createParsedError('--progress 仅支持 deploy 命令', configPath, json);
191
+ }
175
192
  return {
176
193
  args: args.slice(1),
177
194
  command: args[0],
@@ -180,6 +197,7 @@ function parseCliArgs(argv) {
180
197
  dryRun,
181
198
  help,
182
199
  json,
200
+ progress,
183
201
  version,
184
202
  };
185
203
  }
@@ -191,6 +209,7 @@ function createParsedError(error, configPath, json = false) {
191
209
  error,
192
210
  help: false,
193
211
  json,
212
+ progress: false,
194
213
  version: false,
195
214
  };
196
215
  }
@@ -202,7 +221,9 @@ function createDefaultHandlers(configPath) {
202
221
  if (options?.dryRun) {
203
222
  return createDeployPlan(config);
204
223
  }
205
- return deploy(config, createRemoteClient(config));
224
+ return deploy(config, createRemoteClient(config), {
225
+ onProgress: options?.onProgress,
226
+ });
206
227
  },
207
228
  rollback: async (version) => {
208
229
  const config = await loadConfigFile(configPath);
@@ -247,6 +268,12 @@ function printDeployResult(result, io) {
247
268
  if (result.usedFallback) {
248
269
  io.log('远端 tar 不可用或解压失败,已使用逐文件上传');
249
270
  }
271
+ if (result.verified) {
272
+ io.log('远端校验通过');
273
+ for (const check of result.verification ?? []) {
274
+ io.log(`校验: ${check.name} - ${check.message}`);
275
+ }
276
+ }
250
277
  for (const warning of result.warnings) {
251
278
  io.log(`警告: ${warning}`);
252
279
  }
@@ -298,6 +325,14 @@ function printJsonResult(command, result, exitCode, io) {
298
325
  result,
299
326
  }));
300
327
  }
328
+ function printJsonProgress(command, event, io) {
329
+ io.log(JSON.stringify({
330
+ ok: true,
331
+ command,
332
+ event: 'progress',
333
+ ...event,
334
+ }));
335
+ }
301
336
  function printJsonError(command, error, io) {
302
337
  io.log(JSON.stringify({
303
338
  ok: false,
@@ -322,6 +357,7 @@ function createUsageText() {
322
357
  ssh-release doctor [--config <path>]
323
358
  ssh-release deploy [--config <path>]
324
359
  ssh-release deploy --dry-run [--config <path>]
360
+ ssh-release deploy --json --progress [--config <path>]
325
361
  ssh-release list [--config <path>]
326
362
  ssh-release rollback [version] [--config <path>]
327
363
  ssh-release unlock [--confirm <lock-path>] [--config <path>]
package/dist/release.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { access } from 'node:fs/promises';
2
2
  import { createReleasePackage, } from './package.js';
3
- import { acquireRemoteLock } from './lock.js';
3
+ import { acquireRemoteLock, readRemoteLockStatus } from './lock.js';
4
4
  import { readRemoteReleaseNames, remoteJoin, shellQuote, } from './remote.js';
5
5
  export function createVersionName(date = new Date()) {
6
6
  return [
@@ -28,25 +28,25 @@ function pad(value) {
28
28
  return value.toString().padStart(2, '0');
29
29
  }
30
30
  export async function deploy(config, client, options = {}) {
31
- await ensureSourceExists(config.source.path);
31
+ await withDeployProgress(options, 'source', () => ensureSourceExists(config.source.path));
32
32
  const versionName = createVersionName(options.now ?? new Date());
33
33
  const packageFactory = options.createPackage ?? createReleasePackage;
34
- const releaseLock = await acquireRemoteLock(config, client);
34
+ const releaseLock = await withDeployProgress(options, 'lock', () => acquireRemoteLock(config, client));
35
35
  let releasePackage;
36
36
  let result;
37
37
  let deployError;
38
38
  let cleanupError;
39
39
  try {
40
- releasePackage = await packageFactory({
40
+ releasePackage = await withDeployProgress(options, 'package', () => packageFactory({
41
41
  sourcePath: config.source.path,
42
42
  exclude: config.source.exclude,
43
43
  versionName,
44
- });
44
+ }));
45
45
  if (config.deploy.mode === 'overwrite') {
46
- result = await deployOverwrite(config, client, releasePackage, versionName);
46
+ result = await withDeployProgress(options, 'publish', () => deployOverwrite(config, client, releasePackage, versionName));
47
47
  return result;
48
48
  }
49
- result = await deployRelease(config, client, releasePackage, versionName);
49
+ result = await withDeployProgress(options, 'publish', () => deployRelease(config, client, releasePackage, versionName));
50
50
  return result;
51
51
  }
52
52
  catch (error) {
@@ -54,29 +54,35 @@ export async function deploy(config, client, options = {}) {
54
54
  throw error;
55
55
  }
56
56
  finally {
57
- if (releasePackage) {
57
+ await withDeployProgress(options, 'cleanup', async () => {
58
+ if (releasePackage) {
59
+ try {
60
+ await releasePackage.cleanup();
61
+ }
62
+ catch (error) {
63
+ cleanupError = error;
64
+ }
65
+ }
58
66
  try {
59
- await releasePackage.cleanup();
67
+ await releaseLock();
60
68
  }
61
69
  catch (error) {
62
- cleanupError = error;
70
+ const message = `远程发布锁清理失败: ${formatError(error)}`;
71
+ if (result) {
72
+ result.warnings.push(message);
73
+ }
74
+ else if (!deployError && !cleanupError) {
75
+ cleanupError = error;
76
+ }
63
77
  }
64
- }
65
- try {
66
- await releaseLock();
67
- }
68
- catch (error) {
69
- const message = `远程发布锁清理失败: ${formatError(error)}`;
70
- if (result) {
71
- result.warnings.push(message);
78
+ if (result && !deployError && !cleanupError) {
79
+ result.verification = await verifyDeployResult(config, client, result);
80
+ result.verified = true;
72
81
  }
73
- else if (!deployError && !cleanupError) {
74
- cleanupError = error;
82
+ if (cleanupError && !deployError) {
83
+ throw cleanupError;
75
84
  }
76
- }
77
- if (cleanupError && !deployError) {
78
- throw cleanupError;
79
- }
85
+ });
80
86
  }
81
87
  }
82
88
  export async function createDeployPlan(config, options = {}) {
@@ -180,6 +186,64 @@ async function cleanupOldReleases(config, client, releasesPath, currentVersion,
180
186
  warnings.push(`旧版本清理失败: ${formatError(error)}`);
181
187
  }
182
188
  }
189
+ async function verifyDeployResult(config, client, result) {
190
+ if (result.mode === 'overwrite') {
191
+ return [
192
+ await verifyRemoteDirectory(client, result.targetPath, '目标目录', '远端目标目录存在'),
193
+ await verifyRemoteLockReleased(config, client),
194
+ ];
195
+ }
196
+ if (!result.version || !result.currentSymlink) {
197
+ throw new Error('release 发布结果缺少版本号或 current 路径,无法校验远端状态');
198
+ }
199
+ return [
200
+ await verifyRemoteDirectory(client, result.targetPath, '版本目录', '远端版本目录存在'),
201
+ await verifyCurrentSymlink(client, result.currentSymlink, remoteJoin(config.target.releasesDir, result.version)),
202
+ await verifyRemoteLockReleased(config, client),
203
+ ];
204
+ }
205
+ async function verifyRemoteDirectory(client, remotePath, name, successMessage) {
206
+ try {
207
+ await client.exec(`test -d ${shellQuote(remotePath)}`);
208
+ }
209
+ catch (error) {
210
+ throw new Error(`${name}校验失败: ${formatError(error)}`);
211
+ }
212
+ return {
213
+ name,
214
+ status: 'pass',
215
+ message: successMessage,
216
+ };
217
+ }
218
+ async function verifyCurrentSymlink(client, currentSymlinkPath, expectedTarget) {
219
+ let actualTarget;
220
+ try {
221
+ const result = await client.exec(`readlink ${shellQuote(currentSymlinkPath)}`);
222
+ actualTarget = result.stdout.trim();
223
+ }
224
+ catch (error) {
225
+ throw new Error(`current 校验失败: ${formatError(error)}`);
226
+ }
227
+ if (actualTarget !== expectedTarget) {
228
+ throw new Error(`current 未指向新版本: 期望 ${expectedTarget},实际 ${actualTarget || '空'}`);
229
+ }
230
+ return {
231
+ name: '当前版本',
232
+ status: 'pass',
233
+ message: 'current 已指向新版本',
234
+ };
235
+ }
236
+ async function verifyRemoteLockReleased(config, client) {
237
+ const lockStatus = await readRemoteLockStatus(config, client);
238
+ if (lockStatus.locked) {
239
+ throw new Error(`发布锁未清理: ${lockStatus.lockPath}`);
240
+ }
241
+ return {
242
+ name: '远端锁',
243
+ status: 'pass',
244
+ message: '发布锁已清理',
245
+ };
246
+ }
183
247
  async function ensureSourceExists(sourcePath) {
184
248
  try {
185
249
  await access(sourcePath);
@@ -191,3 +255,22 @@ async function ensureSourceExists(sourcePath) {
191
255
  function formatError(error) {
192
256
  return error instanceof Error ? error.message : String(error);
193
257
  }
258
+ async function withDeployProgress(options, stage, run) {
259
+ await emitDeployProgress(options, { stage, status: 'start' });
260
+ try {
261
+ const result = await run();
262
+ await emitDeployProgress(options, { stage, status: 'success' });
263
+ return result;
264
+ }
265
+ catch (error) {
266
+ await emitDeployProgress(options, {
267
+ stage,
268
+ status: 'fail',
269
+ error: formatError(error),
270
+ });
271
+ throw error;
272
+ }
273
+ }
274
+ async function emitDeployProgress(options, event) {
275
+ await options.onProgress?.(event);
276
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ssh-release",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "A general-purpose SSH file release CLI.",
5
5
  "type": "module",
6
6
  "bin": {