ssh-release 0.3.0 → 0.5.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 +48 -1
- package/dist/cli.js +130 -10
- package/dist/lock.js +3 -0
- package/dist/unlock.js +24 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 - 2026-06-25
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- 增加全局 `--json` 输出,支持 `deploy`、`doctor`、`list`、`rollback`、`unlock` 等命令输出单行结构化结果,便于 CI/CD 解析。
|
|
8
|
+
|
|
9
|
+
## 0.4.0 - 2026-06-25
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- 增加 `ssh-release unlock` 安全解锁流程,默认只查看远端锁,必须传入匹配的 `--confirm <lock-path>` 才会删除锁目录。
|
|
14
|
+
|
|
3
15
|
## 0.3.0 - 2026-06-25
|
|
4
16
|
|
|
5
17
|
### Added
|
package/README.md
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
- `ssh-release deploy`:发布本地文件或目录。
|
|
16
16
|
- `ssh-release list`:查看远程版本和当前版本。
|
|
17
17
|
- `ssh-release rollback [version]`:回滚到上一个版本或指定版本。
|
|
18
|
+
- `ssh-release unlock`:查看远端锁,并在显式确认锁路径后删除锁。
|
|
19
|
+
- `--json`:输出单行 JSON,便于 CI/CD 解析。
|
|
18
20
|
- `release` 模式:上传压缩包、远端解压、切换 `current`、清理旧版本。
|
|
19
21
|
- `overwrite` 模式:直接覆盖发布到目标目录。
|
|
20
22
|
- 远端 `tar` 解压失败时回退逐文件上传。
|
|
@@ -55,6 +57,7 @@ npm run dev -- doctor
|
|
|
55
57
|
npm run dev -- deploy
|
|
56
58
|
npm run dev -- list
|
|
57
59
|
npm run dev -- rollback
|
|
60
|
+
npm run dev -- unlock
|
|
58
61
|
```
|
|
59
62
|
|
|
60
63
|
构建 CLI:
|
|
@@ -184,7 +187,21 @@ ssh-release deploy
|
|
|
184
187
|
|
|
185
188
|
`release` 模式发布成功后会输出版本号、版本目录和 `current` 软链接路径。只有压缩包上传和解压完成后才切换 `current`。
|
|
186
189
|
|
|
187
|
-
如果远端已有 `.ssh-release.lock
|
|
190
|
+
如果远端已有 `.ssh-release.lock`,说明同一目标目录可能正在发布或回滚,工具会停止在修改远端状态之前。
|
|
191
|
+
|
|
192
|
+
查看锁状态:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
ssh-release unlock
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
确认没有发布或回滚任务运行后,可以显式确认锁路径并删除锁:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
ssh-release unlock --confirm /var/www/my-app/.ssh-release.lock
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`--confirm` 后的路径必须和当前配置计算出的锁路径完全一致,否则不会删除锁。
|
|
188
205
|
|
|
189
206
|
远端 `tar` 不可用或解压失败时,如果 `deploy.fallbackToFileUpload` 为 `true`,工具会回退逐文件上传。`release` 模式只清理失败的新版本目录;`overwrite` 模式不会先删除整个目标目录。
|
|
190
207
|
|
|
@@ -208,6 +225,32 @@ ssh-release rollback 20260625-150000
|
|
|
208
225
|
|
|
209
226
|
`overwrite` 模式没有版本列表,也不支持回滚。
|
|
210
227
|
|
|
228
|
+
## JSON 输出
|
|
229
|
+
|
|
230
|
+
需要在自动化脚本中解析结果时,可以给命令增加 `--json`:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
ssh-release deploy --dry-run --json
|
|
234
|
+
ssh-release doctor --json
|
|
235
|
+
ssh-release list --json
|
|
236
|
+
ssh-release rollback --json
|
|
237
|
+
ssh-release unlock --json
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
成功时输出单行 JSON:
|
|
241
|
+
|
|
242
|
+
```json
|
|
243
|
+
{"ok":true,"command":"deploy","result":{"dryRun":true,"mode":"release"}}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
命令执行失败时输出:
|
|
247
|
+
|
|
248
|
+
```json
|
|
249
|
+
{"ok":false,"command":"deploy","error":"错误信息"}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`doctor` 检查失败、`unlock` 发现锁但未删除时会返回非零退出码,并在 JSON 的 `result` 中保留检查结果。
|
|
253
|
+
|
|
211
254
|
## 安全边界
|
|
212
255
|
|
|
213
256
|
配置模板不包含真实 IP、密码或生产路径。
|
|
@@ -314,6 +357,7 @@ src/
|
|
|
314
357
|
├── rollback.ts
|
|
315
358
|
├── ssh.ts
|
|
316
359
|
├── types.ts
|
|
360
|
+
├── unlock.ts
|
|
317
361
|
└── validate.ts
|
|
318
362
|
|
|
319
363
|
tests/
|
|
@@ -328,6 +372,7 @@ tests/
|
|
|
328
372
|
├── release.test.ts
|
|
329
373
|
├── rollback.test.ts
|
|
330
374
|
├── ssh.test.ts
|
|
375
|
+
├── unlock.test.ts
|
|
331
376
|
└── validate.test.ts
|
|
332
377
|
|
|
333
378
|
docs/
|
|
@@ -345,11 +390,13 @@ docs/
|
|
|
345
390
|
- 危险远程路径拒绝。
|
|
346
391
|
- 发布模式和压缩格式校验。
|
|
347
392
|
- CLI 命令分发。
|
|
393
|
+
- CLI JSON 输出。
|
|
348
394
|
- 本地 `.tgz` 打包和排除项。
|
|
349
395
|
- macOS AppleDouble 元数据排除。
|
|
350
396
|
- `release` 和 `overwrite` 发布流程。
|
|
351
397
|
- 发布和回滚锁获取、释放和锁冲突拦截。
|
|
352
398
|
- `doctor` 远端锁状态检查和安全清理提示。
|
|
399
|
+
- `unlock` 显式确认路径后删除远端锁。
|
|
353
400
|
- 远端 `tar` 失败后的逐文件上传回退。
|
|
354
401
|
- 远程版本列表读取和当前版本标记。
|
|
355
402
|
- `doctor` 检查结果。
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { listReleases } from './list.js';
|
|
|
8
8
|
import { createDeployPlan, deploy } from './release.js';
|
|
9
9
|
import { rollback } from './rollback.js';
|
|
10
10
|
import { createRemoteClient } from './ssh.js';
|
|
11
|
+
import { unlock } from './unlock.js';
|
|
11
12
|
export function isCliEntrypoint(moduleUrl, argvEntry) {
|
|
12
13
|
if (!argvEntry) {
|
|
13
14
|
return false;
|
|
@@ -18,15 +19,28 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
18
19
|
const io = options.io ?? console;
|
|
19
20
|
const parsed = parseCliArgs(argv);
|
|
20
21
|
if (parsed.error) {
|
|
22
|
+
if (parsed.json) {
|
|
23
|
+
printJsonError(parsed.command, parsed.error, io);
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
21
26
|
io.error(parsed.error);
|
|
22
27
|
return 1;
|
|
23
28
|
}
|
|
24
29
|
if (parsed.help) {
|
|
30
|
+
if (parsed.json) {
|
|
31
|
+
printJsonResult('help', { usage: createUsageText() }, 0, io);
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
25
34
|
printUsage(io);
|
|
26
35
|
return 0;
|
|
27
36
|
}
|
|
28
37
|
if (parsed.version) {
|
|
29
|
-
|
|
38
|
+
const version = readPackageVersion();
|
|
39
|
+
if (parsed.json) {
|
|
40
|
+
printJsonResult('version', { version }, 0, io);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
io.log(version);
|
|
30
44
|
return 0;
|
|
31
45
|
}
|
|
32
46
|
const handlers = options.handlers ?? createDefaultHandlers(parsed.configPath);
|
|
@@ -35,39 +49,87 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
35
49
|
try {
|
|
36
50
|
if (command === 'init') {
|
|
37
51
|
await handlers.init();
|
|
52
|
+
if (parsed.json) {
|
|
53
|
+
printJsonResult('init', { configPath: parsed.configPath }, 0, io);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
38
56
|
io.log(`已创建 ${parsed.configPath}`);
|
|
39
57
|
return 0;
|
|
40
58
|
}
|
|
41
59
|
if (command === 'deploy') {
|
|
42
|
-
|
|
60
|
+
const result = await handlers.deploy({ dryRun: parsed.dryRun });
|
|
61
|
+
if (parsed.json) {
|
|
62
|
+
printJsonResult('deploy', result, 0, io);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
printDeployResult(result, io);
|
|
43
66
|
return 0;
|
|
44
67
|
}
|
|
45
68
|
if (command === 'rollback') {
|
|
46
|
-
|
|
69
|
+
const result = await handlers.rollback(args[0]);
|
|
70
|
+
if (parsed.json) {
|
|
71
|
+
printJsonResult('rollback', result, 0, io);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
printRollbackResult(result, io);
|
|
47
75
|
return 0;
|
|
48
76
|
}
|
|
49
77
|
if (command === 'list') {
|
|
50
|
-
|
|
78
|
+
const result = await handlers.list();
|
|
79
|
+
if (parsed.json) {
|
|
80
|
+
printJsonResult('list', result, 0, io);
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
printListResult(result, io);
|
|
51
84
|
return 0;
|
|
52
85
|
}
|
|
53
86
|
if (command === 'doctor') {
|
|
54
87
|
const report = await handlers.doctor();
|
|
88
|
+
const exitCode = report.ok ? 0 : 1;
|
|
89
|
+
if (parsed.json) {
|
|
90
|
+
printJsonResult('doctor', report, exitCode, io);
|
|
91
|
+
return exitCode;
|
|
92
|
+
}
|
|
55
93
|
printDoctorReport(report, io);
|
|
56
|
-
return
|
|
94
|
+
return exitCode;
|
|
95
|
+
}
|
|
96
|
+
if (command === 'unlock') {
|
|
97
|
+
const result = await handlers.unlock({ confirmPath: parsed.confirmPath });
|
|
98
|
+
const exitCode = result.locked && !result.removed ? 1 : 0;
|
|
99
|
+
if (parsed.json) {
|
|
100
|
+
printJsonResult('unlock', result, exitCode, io);
|
|
101
|
+
return exitCode;
|
|
102
|
+
}
|
|
103
|
+
printUnlockResult(result, io);
|
|
104
|
+
return exitCode;
|
|
105
|
+
}
|
|
106
|
+
if (parsed.json) {
|
|
107
|
+
if (!command) {
|
|
108
|
+
printJsonResult('help', { usage: createUsageText() }, 0, io);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
printJsonError(command, `未知命令: ${command}`, io);
|
|
112
|
+
return 1;
|
|
57
113
|
}
|
|
58
114
|
printUsage(io);
|
|
59
115
|
return command ? 1 : 0;
|
|
60
116
|
}
|
|
61
117
|
catch (error) {
|
|
118
|
+
if (parsed.json) {
|
|
119
|
+
printJsonError(command, formatError(error), io);
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
62
122
|
io.error(error instanceof Error ? error.message : String(error));
|
|
63
123
|
return 1;
|
|
64
124
|
}
|
|
65
125
|
}
|
|
66
126
|
function parseCliArgs(argv) {
|
|
67
127
|
const args = [];
|
|
128
|
+
let confirmPath;
|
|
68
129
|
let configPath = CONFIG_FILE_NAME;
|
|
69
130
|
let dryRun = false;
|
|
70
131
|
let help = false;
|
|
132
|
+
let json = false;
|
|
71
133
|
let version = false;
|
|
72
134
|
for (let index = 0; index < argv.length; index += 1) {
|
|
73
135
|
const arg = argv[index];
|
|
@@ -79,10 +141,14 @@ function parseCliArgs(argv) {
|
|
|
79
141
|
version = true;
|
|
80
142
|
continue;
|
|
81
143
|
}
|
|
144
|
+
if (arg === '--json') {
|
|
145
|
+
json = true;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
82
148
|
if (arg === '--config' || arg === '-c') {
|
|
83
149
|
const value = argv[index + 1];
|
|
84
150
|
if (!value || value.startsWith('-')) {
|
|
85
|
-
return createParsedError('--config 需要配置文件路径', configPath);
|
|
151
|
+
return createParsedError('--config 需要配置文件路径', configPath, json);
|
|
86
152
|
}
|
|
87
153
|
configPath = value;
|
|
88
154
|
index += 1;
|
|
@@ -92,27 +158,39 @@ function parseCliArgs(argv) {
|
|
|
92
158
|
dryRun = true;
|
|
93
159
|
continue;
|
|
94
160
|
}
|
|
161
|
+
if (arg === '--confirm') {
|
|
162
|
+
const value = argv[index + 1];
|
|
163
|
+
if (!value || value.startsWith('-')) {
|
|
164
|
+
return createParsedError('--confirm 需要远端锁路径', configPath, json);
|
|
165
|
+
}
|
|
166
|
+
confirmPath = value;
|
|
167
|
+
index += 1;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
95
170
|
if (arg.startsWith('-')) {
|
|
96
|
-
return createParsedError(`未知选项: ${arg}`, configPath);
|
|
171
|
+
return createParsedError(`未知选项: ${arg}`, configPath, json);
|
|
97
172
|
}
|
|
98
173
|
args.push(arg);
|
|
99
174
|
}
|
|
100
175
|
return {
|
|
101
176
|
args: args.slice(1),
|
|
102
177
|
command: args[0],
|
|
178
|
+
confirmPath,
|
|
103
179
|
configPath,
|
|
104
180
|
dryRun,
|
|
105
181
|
help,
|
|
182
|
+
json,
|
|
106
183
|
version,
|
|
107
184
|
};
|
|
108
185
|
}
|
|
109
|
-
function createParsedError(error, configPath) {
|
|
186
|
+
function createParsedError(error, configPath, json = false) {
|
|
110
187
|
return {
|
|
111
188
|
args: [],
|
|
112
189
|
configPath,
|
|
113
190
|
dryRun: false,
|
|
114
191
|
error,
|
|
115
192
|
help: false,
|
|
193
|
+
json,
|
|
116
194
|
version: false,
|
|
117
195
|
};
|
|
118
196
|
}
|
|
@@ -137,6 +215,10 @@ function createDefaultHandlers(configPath) {
|
|
|
137
215
|
doctor: async () => {
|
|
138
216
|
return runDoctorFromFile(configPath, createRemoteClient);
|
|
139
217
|
},
|
|
218
|
+
unlock: async (options) => {
|
|
219
|
+
const config = await loadConfigFile(configPath);
|
|
220
|
+
return unlock(config, createRemoteClient(config), options);
|
|
221
|
+
},
|
|
140
222
|
};
|
|
141
223
|
}
|
|
142
224
|
function printDeployResult(result, io) {
|
|
@@ -193,6 +275,36 @@ function printDoctorReport(report, io) {
|
|
|
193
275
|
io.log(`[${check.status}] ${check.name}: ${check.message}`);
|
|
194
276
|
}
|
|
195
277
|
}
|
|
278
|
+
function printUnlockResult(result, io) {
|
|
279
|
+
if (!result.locked) {
|
|
280
|
+
io.log(`没有发现远端锁: ${result.lockPath}`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (result.removed) {
|
|
284
|
+
io.log(`已删除远端锁: ${result.lockPath}`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
io.log(`发现远端锁: ${result.lockPath}`);
|
|
288
|
+
io.log(`pid: ${result.pid ?? '未知'}`);
|
|
289
|
+
io.log(`创建时间: ${result.createdAt ?? '未知'}`);
|
|
290
|
+
io.log('不会自动删除远端锁');
|
|
291
|
+
io.log('确认没有发布或回滚任务后再执行:');
|
|
292
|
+
io.log(`ssh-release unlock --confirm ${result.lockPath}`);
|
|
293
|
+
}
|
|
294
|
+
function printJsonResult(command, result, exitCode, io) {
|
|
295
|
+
io.log(JSON.stringify({
|
|
296
|
+
ok: exitCode === 0,
|
|
297
|
+
command,
|
|
298
|
+
result,
|
|
299
|
+
}));
|
|
300
|
+
}
|
|
301
|
+
function printJsonError(command, error, io) {
|
|
302
|
+
io.log(JSON.stringify({
|
|
303
|
+
ok: false,
|
|
304
|
+
command,
|
|
305
|
+
error,
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
196
308
|
function toRealPath(filePath) {
|
|
197
309
|
try {
|
|
198
310
|
return realpathSync(filePath);
|
|
@@ -202,20 +314,28 @@ function toRealPath(filePath) {
|
|
|
202
314
|
}
|
|
203
315
|
}
|
|
204
316
|
function printUsage(io) {
|
|
205
|
-
io.log(
|
|
317
|
+
io.log(createUsageText());
|
|
318
|
+
}
|
|
319
|
+
function createUsageText() {
|
|
320
|
+
return `用法:
|
|
206
321
|
ssh-release init [--config <path>]
|
|
207
322
|
ssh-release doctor [--config <path>]
|
|
208
323
|
ssh-release deploy [--config <path>]
|
|
209
324
|
ssh-release deploy --dry-run [--config <path>]
|
|
210
325
|
ssh-release list [--config <path>]
|
|
211
326
|
ssh-release rollback [version] [--config <path>]
|
|
327
|
+
ssh-release unlock [--confirm <lock-path>] [--config <path>]
|
|
328
|
+
ssh-release <command> --json
|
|
212
329
|
ssh-release --help
|
|
213
|
-
ssh-release --version
|
|
330
|
+
ssh-release --version`;
|
|
214
331
|
}
|
|
215
332
|
function readPackageVersion() {
|
|
216
333
|
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
217
334
|
return packageJson.version ?? '0.0.0';
|
|
218
335
|
}
|
|
336
|
+
function formatError(error) {
|
|
337
|
+
return error instanceof Error ? error.message : String(error);
|
|
338
|
+
}
|
|
219
339
|
if (isCliEntrypoint(import.meta.url, process.argv[1])) {
|
|
220
340
|
const exitCode = await runCli();
|
|
221
341
|
process.exit(exitCode);
|
package/dist/lock.js
CHANGED
|
@@ -38,6 +38,9 @@ export async function acquireRemoteLock(config, client, options = {}) {
|
|
|
38
38
|
await client.exec(`rm -rf ${shellQuote(lockPath)}`);
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
|
+
export async function removeRemoteLock(config, client) {
|
|
42
|
+
await client.exec(`rm -rf ${shellQuote(getRemoteLockPath(config))}`);
|
|
43
|
+
}
|
|
41
44
|
function parseRemoteLockValue(lines, fieldName) {
|
|
42
45
|
const prefix = `${fieldName}=`;
|
|
43
46
|
const line = lines.find((entry) => entry.startsWith(prefix));
|
package/dist/unlock.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readRemoteLockStatus, removeRemoteLock, } from './lock.js';
|
|
2
|
+
export async function unlock(config, client, options = {}) {
|
|
3
|
+
const lockStatus = await readRemoteLockStatus(config, client);
|
|
4
|
+
if (!lockStatus.locked) {
|
|
5
|
+
return {
|
|
6
|
+
...lockStatus,
|
|
7
|
+
removed: false,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
if (!options.confirmPath) {
|
|
11
|
+
return {
|
|
12
|
+
...lockStatus,
|
|
13
|
+
removed: false,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
if (options.confirmPath !== lockStatus.lockPath) {
|
|
17
|
+
throw new Error(`确认路径不匹配: 需要 ${lockStatus.lockPath}`);
|
|
18
|
+
}
|
|
19
|
+
await removeRemoteLock(config, client);
|
|
20
|
+
return {
|
|
21
|
+
...lockStatus,
|
|
22
|
+
removed: true,
|
|
23
|
+
};
|
|
24
|
+
}
|