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 +12 -0
- package/README.md +46 -0
- package/dist/cli.js +126 -14
- package/dist/release.js +45 -24
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
67
|
+
await releaseLock();
|
|
60
68
|
}
|
|
61
69
|
catch (error) {
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|