ssh-release 0.5.0 → 0.6.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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 - 2026-06-25
4
+
5
+ ### Added
6
+
7
+ - 增加 `ssh-release deploy --json --progress`,发布时按 NDJSON 输出 `source`、`lock`、`package`、`publish`、`cleanup` 阶段状态,并在最后输出发布结果。
8
+
3
9
  ## 0.5.0 - 2026-06-25
4
10
 
5
11
  ### Added
package/README.md CHANGED
@@ -17,6 +17,7 @@
17
17
  - `ssh-release rollback [version]`:回滚到上一个版本或指定版本。
18
18
  - `ssh-release unlock`:查看远端锁,并在显式确认锁路径后删除锁。
19
19
  - `--json`:输出单行 JSON,便于 CI/CD 解析。
20
+ - `deploy --json --progress`:发布时输出 NDJSON 阶段进度,便于 CI/CD 展示实时状态。
20
21
  - `release` 模式:上传压缩包、远端解压、切换 `current`、清理旧版本。
21
22
  - `overwrite` 模式:直接覆盖发布到目标目录。
22
23
  - 远端 `tar` 解压失败时回退逐文件上传。
@@ -231,6 +232,7 @@ ssh-release rollback 20260625-150000
231
232
 
232
233
  ```bash
233
234
  ssh-release deploy --dry-run --json
235
+ ssh-release deploy --json --progress
234
236
  ssh-release doctor --json
235
237
  ssh-release list --json
236
238
  ssh-release rollback --json
@@ -249,6 +251,22 @@ ssh-release unlock --json
249
251
  {"ok":false,"command":"deploy","error":"错误信息"}
250
252
  ```
251
253
 
254
+ 发布时需要持续读取阶段状态,可以使用:
255
+
256
+ ```bash
257
+ ssh-release deploy --json --progress
258
+ ```
259
+
260
+ 该模式会按行输出 NDJSON。进度事件先输出,最终结果最后输出:
261
+
262
+ ```json
263
+ {"ok":true,"command":"deploy","event":"progress","stage":"package","status":"start"}
264
+ {"ok":true,"command":"deploy","event":"progress","stage":"package","status":"success"}
265
+ {"ok":true,"command":"deploy","result":{"mode":"release","version":"20260625-153000"}}
266
+ ```
267
+
268
+ `stage` 可能是 `source`、`lock`、`package`、`publish`、`cleanup`,`status` 可能是 `start`、`success`、`fail`。失败事件会包含 `error` 字段。
269
+
252
270
  `doctor` 检查失败、`unlock` 发现锁但未删除时会返回非零退出码,并在 JSON 的 `result` 中保留检查结果。
253
271
 
254
272
  ## 安全边界
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);
@@ -298,6 +319,14 @@ function printJsonResult(command, result, exitCode, io) {
298
319
  result,
299
320
  }));
300
321
  }
322
+ function printJsonProgress(command, event, io) {
323
+ io.log(JSON.stringify({
324
+ ok: true,
325
+ command,
326
+ event: 'progress',
327
+ ...event,
328
+ }));
329
+ }
301
330
  function printJsonError(command, error, io) {
302
331
  io.log(JSON.stringify({
303
332
  ok: false,
@@ -322,6 +351,7 @@ function createUsageText() {
322
351
  ssh-release doctor [--config <path>]
323
352
  ssh-release deploy [--config <path>]
324
353
  ssh-release deploy --dry-run [--config <path>]
354
+ ssh-release deploy --json --progress [--config <path>]
325
355
  ssh-release list [--config <path>]
326
356
  ssh-release rollback [version] [--config <path>]
327
357
  ssh-release unlock [--confirm <lock-path>] [--config <path>]
package/dist/release.js CHANGED
@@ -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,31 @@ 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 (cleanupError && !deployError) {
79
+ throw cleanupError;
72
80
  }
73
- else if (!deployError && !cleanupError) {
74
- cleanupError = error;
75
- }
76
- }
77
- if (cleanupError && !deployError) {
78
- throw cleanupError;
79
- }
81
+ });
80
82
  }
81
83
  }
82
84
  export async function createDeployPlan(config, options = {}) {
@@ -191,3 +193,22 @@ async function ensureSourceExists(sourcePath) {
191
193
  function formatError(error) {
192
194
  return error instanceof Error ? error.message : String(error);
193
195
  }
196
+ async function withDeployProgress(options, stage, run) {
197
+ await emitDeployProgress(options, { stage, status: 'start' });
198
+ try {
199
+ const result = await run();
200
+ await emitDeployProgress(options, { stage, status: 'success' });
201
+ return result;
202
+ }
203
+ catch (error) {
204
+ await emitDeployProgress(options, {
205
+ stage,
206
+ status: 'fail',
207
+ error: formatError(error),
208
+ });
209
+ throw error;
210
+ }
211
+ }
212
+ async function emitDeployProgress(options, event) {
213
+ await options.onProgress?.(event);
214
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ssh-release",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A general-purpose SSH file release CLI.",
5
5
  "type": "module",
6
6
  "bin": {