ssh-release 0.4.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,17 @@
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
+
9
+ ## 0.5.0 - 2026-06-25
10
+
11
+ ### Added
12
+
13
+ - 增加全局 `--json` 输出,支持 `deploy`、`doctor`、`list`、`rollback`、`unlock` 等命令输出单行结构化结果,便于 CI/CD 解析。
14
+
3
15
  ## 0.4.0 - 2026-06-25
4
16
 
5
17
  ### Added
package/README.md CHANGED
@@ -16,6 +16,8 @@
16
16
  - `ssh-release list`:查看远程版本和当前版本。
17
17
  - `ssh-release rollback [version]`:回滚到上一个版本或指定版本。
18
18
  - `ssh-release unlock`:查看远端锁,并在显式确认锁路径后删除锁。
19
+ - `--json`:输出单行 JSON,便于 CI/CD 解析。
20
+ - `deploy --json --progress`:发布时输出 NDJSON 阶段进度,便于 CI/CD 展示实时状态。
19
21
  - `release` 模式:上传压缩包、远端解压、切换 `current`、清理旧版本。
20
22
  - `overwrite` 模式:直接覆盖发布到目标目录。
21
23
  - 远端 `tar` 解压失败时回退逐文件上传。
@@ -224,6 +226,49 @@ ssh-release rollback 20260625-150000
224
226
 
225
227
  `overwrite` 模式没有版本列表,也不支持回滚。
226
228
 
229
+ ## JSON 输出
230
+
231
+ 需要在自动化脚本中解析结果时,可以给命令增加 `--json`:
232
+
233
+ ```bash
234
+ ssh-release deploy --dry-run --json
235
+ ssh-release deploy --json --progress
236
+ ssh-release doctor --json
237
+ ssh-release list --json
238
+ ssh-release rollback --json
239
+ ssh-release unlock --json
240
+ ```
241
+
242
+ 成功时输出单行 JSON:
243
+
244
+ ```json
245
+ {"ok":true,"command":"deploy","result":{"dryRun":true,"mode":"release"}}
246
+ ```
247
+
248
+ 命令执行失败时输出:
249
+
250
+ ```json
251
+ {"ok":false,"command":"deploy","error":"错误信息"}
252
+ ```
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
+
270
+ `doctor` 检查失败、`unlock` 发现锁但未删除时会返回非零退出码,并在 JSON 的 `result` 中保留检查结果。
271
+
227
272
  ## 安全边界
228
273
 
229
274
  配置模板不包含真实 IP、密码或生产路径。
@@ -363,6 +408,7 @@ docs/
363
408
  - 危险远程路径拒绝。
364
409
  - 发布模式和压缩格式校验。
365
410
  - CLI 命令分发。
411
+ - CLI JSON 输出。
366
412
  - 本地 `.tgz` 打包和排除项。
367
413
  - macOS AppleDouble 元数据排除。
368
414
  - `release` 和 `overwrite` 发布流程。
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';
@@ -19,15 +19,28 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
19
19
  const io = options.io ?? console;
20
20
  const parsed = parseCliArgs(argv);
21
21
  if (parsed.error) {
22
+ if (parsed.json) {
23
+ printJsonError(parsed.command, parsed.error, io);
24
+ return 1;
25
+ }
22
26
  io.error(parsed.error);
23
27
  return 1;
24
28
  }
25
29
  if (parsed.help) {
30
+ if (parsed.json) {
31
+ printJsonResult('help', { usage: createUsageText() }, 0, io);
32
+ return 0;
33
+ }
26
34
  printUsage(io);
27
35
  return 0;
28
36
  }
29
37
  if (parsed.version) {
30
- io.log(readPackageVersion());
38
+ const version = readPackageVersion();
39
+ if (parsed.json) {
40
+ printJsonResult('version', { version }, 0, io);
41
+ return 0;
42
+ }
43
+ io.log(version);
31
44
  return 0;
32
45
  }
33
46
  const handlers = options.handlers ?? createDefaultHandlers(parsed.configPath);
@@ -36,35 +49,82 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
36
49
  try {
37
50
  if (command === 'init') {
38
51
  await handlers.init();
52
+ if (parsed.json) {
53
+ printJsonResult('init', { configPath: parsed.configPath }, 0, io);
54
+ return 0;
55
+ }
39
56
  io.log(`已创建 ${parsed.configPath}`);
40
57
  return 0;
41
58
  }
42
59
  if (command === 'deploy') {
43
- printDeployResult(await handlers.deploy({ dryRun: parsed.dryRun }), io);
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);
67
+ if (parsed.json) {
68
+ printJsonResult('deploy', result, 0, io);
69
+ return 0;
70
+ }
71
+ printDeployResult(result, io);
44
72
  return 0;
45
73
  }
46
74
  if (command === 'rollback') {
47
- printRollbackResult(await handlers.rollback(args[0]), io);
75
+ const result = await handlers.rollback(args[0]);
76
+ if (parsed.json) {
77
+ printJsonResult('rollback', result, 0, io);
78
+ return 0;
79
+ }
80
+ printRollbackResult(result, io);
48
81
  return 0;
49
82
  }
50
83
  if (command === 'list') {
51
- printListResult(await handlers.list(), io);
84
+ const result = await handlers.list();
85
+ if (parsed.json) {
86
+ printJsonResult('list', result, 0, io);
87
+ return 0;
88
+ }
89
+ printListResult(result, io);
52
90
  return 0;
53
91
  }
54
92
  if (command === 'doctor') {
55
93
  const report = await handlers.doctor();
94
+ const exitCode = report.ok ? 0 : 1;
95
+ if (parsed.json) {
96
+ printJsonResult('doctor', report, exitCode, io);
97
+ return exitCode;
98
+ }
56
99
  printDoctorReport(report, io);
57
- return report.ok ? 0 : 1;
100
+ return exitCode;
58
101
  }
59
102
  if (command === 'unlock') {
60
103
  const result = await handlers.unlock({ confirmPath: parsed.confirmPath });
104
+ const exitCode = result.locked && !result.removed ? 1 : 0;
105
+ if (parsed.json) {
106
+ printJsonResult('unlock', result, exitCode, io);
107
+ return exitCode;
108
+ }
61
109
  printUnlockResult(result, io);
62
- return result.locked && !result.removed ? 1 : 0;
110
+ return exitCode;
111
+ }
112
+ if (parsed.json) {
113
+ if (!command) {
114
+ printJsonResult('help', { usage: createUsageText() }, 0, io);
115
+ return 0;
116
+ }
117
+ printJsonError(command, `未知命令: ${command}`, io);
118
+ return 1;
63
119
  }
64
120
  printUsage(io);
65
121
  return command ? 1 : 0;
66
122
  }
67
123
  catch (error) {
124
+ if (parsed.json) {
125
+ printJsonError(command, formatError(error), io);
126
+ return 1;
127
+ }
68
128
  io.error(error instanceof Error ? error.message : String(error));
69
129
  return 1;
70
130
  }
@@ -75,6 +135,8 @@ function parseCliArgs(argv) {
75
135
  let configPath = CONFIG_FILE_NAME;
76
136
  let dryRun = false;
77
137
  let help = false;
138
+ let json = false;
139
+ let progress = false;
78
140
  let version = false;
79
141
  for (let index = 0; index < argv.length; index += 1) {
80
142
  const arg = argv[index];
@@ -86,10 +148,18 @@ function parseCliArgs(argv) {
86
148
  version = true;
87
149
  continue;
88
150
  }
151
+ if (arg === '--json') {
152
+ json = true;
153
+ continue;
154
+ }
155
+ if (arg === '--progress') {
156
+ progress = true;
157
+ continue;
158
+ }
89
159
  if (arg === '--config' || arg === '-c') {
90
160
  const value = argv[index + 1];
91
161
  if (!value || value.startsWith('-')) {
92
- return createParsedError('--config 需要配置文件路径', configPath);
162
+ return createParsedError('--config 需要配置文件路径', configPath, json);
93
163
  }
94
164
  configPath = value;
95
165
  index += 1;
@@ -102,17 +172,23 @@ function parseCliArgs(argv) {
102
172
  if (arg === '--confirm') {
103
173
  const value = argv[index + 1];
104
174
  if (!value || value.startsWith('-')) {
105
- return createParsedError('--confirm 需要远端锁路径', configPath);
175
+ return createParsedError('--confirm 需要远端锁路径', configPath, json);
106
176
  }
107
177
  confirmPath = value;
108
178
  index += 1;
109
179
  continue;
110
180
  }
111
181
  if (arg.startsWith('-')) {
112
- return createParsedError(`未知选项: ${arg}`, configPath);
182
+ return createParsedError(`未知选项: ${arg}`, configPath, json);
113
183
  }
114
184
  args.push(arg);
115
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
+ }
116
192
  return {
117
193
  args: args.slice(1),
118
194
  command: args[0],
@@ -120,16 +196,20 @@ function parseCliArgs(argv) {
120
196
  configPath,
121
197
  dryRun,
122
198
  help,
199
+ json,
200
+ progress,
123
201
  version,
124
202
  };
125
203
  }
126
- function createParsedError(error, configPath) {
204
+ function createParsedError(error, configPath, json = false) {
127
205
  return {
128
206
  args: [],
129
207
  configPath,
130
208
  dryRun: false,
131
209
  error,
132
210
  help: false,
211
+ json,
212
+ progress: false,
133
213
  version: false,
134
214
  };
135
215
  }
@@ -141,7 +221,9 @@ function createDefaultHandlers(configPath) {
141
221
  if (options?.dryRun) {
142
222
  return createDeployPlan(config);
143
223
  }
144
- return deploy(config, createRemoteClient(config));
224
+ return deploy(config, createRemoteClient(config), {
225
+ onProgress: options?.onProgress,
226
+ });
145
227
  },
146
228
  rollback: async (version) => {
147
229
  const config = await loadConfigFile(configPath);
@@ -230,6 +312,28 @@ function printUnlockResult(result, io) {
230
312
  io.log('确认没有发布或回滚任务后再执行:');
231
313
  io.log(`ssh-release unlock --confirm ${result.lockPath}`);
232
314
  }
315
+ function printJsonResult(command, result, exitCode, io) {
316
+ io.log(JSON.stringify({
317
+ ok: exitCode === 0,
318
+ command,
319
+ result,
320
+ }));
321
+ }
322
+ function printJsonProgress(command, event, io) {
323
+ io.log(JSON.stringify({
324
+ ok: true,
325
+ command,
326
+ event: 'progress',
327
+ ...event,
328
+ }));
329
+ }
330
+ function printJsonError(command, error, io) {
331
+ io.log(JSON.stringify({
332
+ ok: false,
333
+ command,
334
+ error,
335
+ }));
336
+ }
233
337
  function toRealPath(filePath) {
234
338
  try {
235
339
  return realpathSync(filePath);
@@ -239,21 +343,29 @@ function toRealPath(filePath) {
239
343
  }
240
344
  }
241
345
  function printUsage(io) {
242
- io.log(`用法:
346
+ io.log(createUsageText());
347
+ }
348
+ function createUsageText() {
349
+ return `用法:
243
350
  ssh-release init [--config <path>]
244
351
  ssh-release doctor [--config <path>]
245
352
  ssh-release deploy [--config <path>]
246
353
  ssh-release deploy --dry-run [--config <path>]
354
+ ssh-release deploy --json --progress [--config <path>]
247
355
  ssh-release list [--config <path>]
248
356
  ssh-release rollback [version] [--config <path>]
249
357
  ssh-release unlock [--confirm <lock-path>] [--config <path>]
358
+ ssh-release <command> --json
250
359
  ssh-release --help
251
- ssh-release --version`);
360
+ ssh-release --version`;
252
361
  }
253
362
  function readPackageVersion() {
254
363
  const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
255
364
  return packageJson.version ?? '0.0.0';
256
365
  }
366
+ function formatError(error) {
367
+ return error instanceof Error ? error.message : String(error);
368
+ }
257
369
  if (isCliEntrypoint(import.meta.url, process.argv[1])) {
258
370
  const exitCode = await runCli();
259
371
  process.exit(exitCode);
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.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "A general-purpose SSH file release CLI.",
5
5
  "type": "module",
6
6
  "bin": {