ssh-release 0.2.0 → 0.4.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 +24 -2
- package/dist/cli.js +38 -0
- package/dist/doctor.js +32 -0
- package/dist/lock.js +28 -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.4.0 - 2026-06-25
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- 增加 `ssh-release unlock` 安全解锁流程,默认只查看远端锁,必须传入匹配的 `--confirm <lock-path>` 才会删除锁目录。
|
|
8
|
+
|
|
9
|
+
## 0.3.0 - 2026-06-25
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- 增加 `ssh-release doctor` 远端锁状态检查,无锁时通过,有锁时提示锁路径、pid、创建时间和安全清理条件。
|
|
14
|
+
|
|
3
15
|
## 0.2.0 - 2026-06-25
|
|
4
16
|
|
|
5
17
|
### Added
|
package/README.md
CHANGED
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
已实现:
|
|
12
12
|
|
|
13
13
|
- `ssh-release init`:生成 `ssh-release.config.ts` 配置模板。
|
|
14
|
-
- `ssh-release doctor`:检查配置、本地源路径、SSH
|
|
14
|
+
- `ssh-release doctor`:检查配置、本地源路径、SSH 连接、远程目录、远端锁和远端 `tar`。
|
|
15
15
|
- `ssh-release deploy`:发布本地文件或目录。
|
|
16
16
|
- `ssh-release list`:查看远程版本和当前版本。
|
|
17
17
|
- `ssh-release rollback [version]`:回滚到上一个版本或指定版本。
|
|
18
|
+
- `ssh-release unlock`:查看远端锁,并在显式确认锁路径后删除锁。
|
|
18
19
|
- `release` 模式:上传压缩包、远端解压、切换 `current`、清理旧版本。
|
|
19
20
|
- `overwrite` 模式:直接覆盖发布到目标目录。
|
|
20
21
|
- 远端 `tar` 解压失败时回退逐文件上传。
|
|
@@ -55,6 +56,7 @@ npm run dev -- doctor
|
|
|
55
56
|
npm run dev -- deploy
|
|
56
57
|
npm run dev -- list
|
|
57
58
|
npm run dev -- rollback
|
|
59
|
+
npm run dev -- unlock
|
|
58
60
|
```
|
|
59
61
|
|
|
60
62
|
构建 CLI:
|
|
@@ -168,6 +170,8 @@ source files -> package -> upload -> release -> activate -> rollback
|
|
|
168
170
|
ssh-release doctor
|
|
169
171
|
```
|
|
170
172
|
|
|
173
|
+
`doctor` 会检查 `.ssh-release.lock`。没有锁时正常通过;如果发现锁,会以 `warn` 显示锁路径、pid、创建时间和安全清理提示。
|
|
174
|
+
|
|
171
175
|
需要先查看发布计划但不修改服务器时:
|
|
172
176
|
|
|
173
177
|
```bash
|
|
@@ -182,7 +186,21 @@ ssh-release deploy
|
|
|
182
186
|
|
|
183
187
|
`release` 模式发布成功后会输出版本号、版本目录和 `current` 软链接路径。只有压缩包上传和解压完成后才切换 `current`。
|
|
184
188
|
|
|
185
|
-
如果远端已有 `.ssh-release.lock
|
|
189
|
+
如果远端已有 `.ssh-release.lock`,说明同一目标目录可能正在发布或回滚,工具会停止在修改远端状态之前。
|
|
190
|
+
|
|
191
|
+
查看锁状态:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
ssh-release unlock
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
确认没有发布或回滚任务运行后,可以显式确认锁路径并删除锁:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
ssh-release unlock --confirm /var/www/my-app/.ssh-release.lock
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`--confirm` 后的路径必须和当前配置计算出的锁路径完全一致,否则不会删除锁。
|
|
186
204
|
|
|
187
205
|
远端 `tar` 不可用或解压失败时,如果 `deploy.fallbackToFileUpload` 为 `true`,工具会回退逐文件上传。`release` 模式只清理失败的新版本目录;`overwrite` 模式不会先删除整个目标目录。
|
|
188
206
|
|
|
@@ -312,6 +330,7 @@ src/
|
|
|
312
330
|
├── rollback.ts
|
|
313
331
|
├── ssh.ts
|
|
314
332
|
├── types.ts
|
|
333
|
+
├── unlock.ts
|
|
315
334
|
└── validate.ts
|
|
316
335
|
|
|
317
336
|
tests/
|
|
@@ -326,6 +345,7 @@ tests/
|
|
|
326
345
|
├── release.test.ts
|
|
327
346
|
├── rollback.test.ts
|
|
328
347
|
├── ssh.test.ts
|
|
348
|
+
├── unlock.test.ts
|
|
329
349
|
└── validate.test.ts
|
|
330
350
|
|
|
331
351
|
docs/
|
|
@@ -347,6 +367,8 @@ docs/
|
|
|
347
367
|
- macOS AppleDouble 元数据排除。
|
|
348
368
|
- `release` 和 `overwrite` 发布流程。
|
|
349
369
|
- 发布和回滚锁获取、释放和锁冲突拦截。
|
|
370
|
+
- `doctor` 远端锁状态检查和安全清理提示。
|
|
371
|
+
- `unlock` 显式确认路径后删除远端锁。
|
|
350
372
|
- 远端 `tar` 失败后的逐文件上传回退。
|
|
351
373
|
- 远程版本列表读取和当前版本标记。
|
|
352
374
|
- `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;
|
|
@@ -55,6 +56,11 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
55
56
|
printDoctorReport(report, io);
|
|
56
57
|
return report.ok ? 0 : 1;
|
|
57
58
|
}
|
|
59
|
+
if (command === 'unlock') {
|
|
60
|
+
const result = await handlers.unlock({ confirmPath: parsed.confirmPath });
|
|
61
|
+
printUnlockResult(result, io);
|
|
62
|
+
return result.locked && !result.removed ? 1 : 0;
|
|
63
|
+
}
|
|
58
64
|
printUsage(io);
|
|
59
65
|
return command ? 1 : 0;
|
|
60
66
|
}
|
|
@@ -65,6 +71,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
65
71
|
}
|
|
66
72
|
function parseCliArgs(argv) {
|
|
67
73
|
const args = [];
|
|
74
|
+
let confirmPath;
|
|
68
75
|
let configPath = CONFIG_FILE_NAME;
|
|
69
76
|
let dryRun = false;
|
|
70
77
|
let help = false;
|
|
@@ -92,6 +99,15 @@ function parseCliArgs(argv) {
|
|
|
92
99
|
dryRun = true;
|
|
93
100
|
continue;
|
|
94
101
|
}
|
|
102
|
+
if (arg === '--confirm') {
|
|
103
|
+
const value = argv[index + 1];
|
|
104
|
+
if (!value || value.startsWith('-')) {
|
|
105
|
+
return createParsedError('--confirm 需要远端锁路径', configPath);
|
|
106
|
+
}
|
|
107
|
+
confirmPath = value;
|
|
108
|
+
index += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
95
111
|
if (arg.startsWith('-')) {
|
|
96
112
|
return createParsedError(`未知选项: ${arg}`, configPath);
|
|
97
113
|
}
|
|
@@ -100,6 +116,7 @@ function parseCliArgs(argv) {
|
|
|
100
116
|
return {
|
|
101
117
|
args: args.slice(1),
|
|
102
118
|
command: args[0],
|
|
119
|
+
confirmPath,
|
|
103
120
|
configPath,
|
|
104
121
|
dryRun,
|
|
105
122
|
help,
|
|
@@ -137,6 +154,10 @@ function createDefaultHandlers(configPath) {
|
|
|
137
154
|
doctor: async () => {
|
|
138
155
|
return runDoctorFromFile(configPath, createRemoteClient);
|
|
139
156
|
},
|
|
157
|
+
unlock: async (options) => {
|
|
158
|
+
const config = await loadConfigFile(configPath);
|
|
159
|
+
return unlock(config, createRemoteClient(config), options);
|
|
160
|
+
},
|
|
140
161
|
};
|
|
141
162
|
}
|
|
142
163
|
function printDeployResult(result, io) {
|
|
@@ -193,6 +214,22 @@ function printDoctorReport(report, io) {
|
|
|
193
214
|
io.log(`[${check.status}] ${check.name}: ${check.message}`);
|
|
194
215
|
}
|
|
195
216
|
}
|
|
217
|
+
function printUnlockResult(result, io) {
|
|
218
|
+
if (!result.locked) {
|
|
219
|
+
io.log(`没有发现远端锁: ${result.lockPath}`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (result.removed) {
|
|
223
|
+
io.log(`已删除远端锁: ${result.lockPath}`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
io.log(`发现远端锁: ${result.lockPath}`);
|
|
227
|
+
io.log(`pid: ${result.pid ?? '未知'}`);
|
|
228
|
+
io.log(`创建时间: ${result.createdAt ?? '未知'}`);
|
|
229
|
+
io.log('不会自动删除远端锁');
|
|
230
|
+
io.log('确认没有发布或回滚任务后再执行:');
|
|
231
|
+
io.log(`ssh-release unlock --confirm ${result.lockPath}`);
|
|
232
|
+
}
|
|
196
233
|
function toRealPath(filePath) {
|
|
197
234
|
try {
|
|
198
235
|
return realpathSync(filePath);
|
|
@@ -209,6 +246,7 @@ function printUsage(io) {
|
|
|
209
246
|
ssh-release deploy --dry-run [--config <path>]
|
|
210
247
|
ssh-release list [--config <path>]
|
|
211
248
|
ssh-release rollback [version] [--config <path>]
|
|
249
|
+
ssh-release unlock [--confirm <lock-path>] [--config <path>]
|
|
212
250
|
ssh-release --help
|
|
213
251
|
ssh-release --version`);
|
|
214
252
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { access } from 'node:fs/promises';
|
|
2
2
|
import { loadConfigFile } from './config.js';
|
|
3
|
+
import { readRemoteLockStatus } from './lock.js';
|
|
3
4
|
import { shellQuote } from './remote.js';
|
|
4
5
|
export async function runDoctorFromFile(configPath, createClient) {
|
|
5
6
|
let config;
|
|
@@ -33,6 +34,7 @@ export async function runDoctor(config, client, options = {}) {
|
|
|
33
34
|
await addSourcePathCheck(checks, config.source.path);
|
|
34
35
|
await addRemoteCheck(checks, 'SSH 连接', () => client.exec('true'), 'SSH 可连接');
|
|
35
36
|
await addRemoteCheck(checks, '远程目录', () => client.exec(`mkdir -p ${shellQuote(config.target.path)} && test -w ${shellQuote(config.target.path)}`), '远程目录可创建且可写');
|
|
37
|
+
await addRemoteLockCheck(checks, config, client);
|
|
36
38
|
try {
|
|
37
39
|
await client.exec('command -v tar');
|
|
38
40
|
checks.push({
|
|
@@ -78,6 +80,36 @@ async function addConfigFileCheck(checks, configPath) {
|
|
|
78
80
|
});
|
|
79
81
|
}
|
|
80
82
|
}
|
|
83
|
+
async function addRemoteLockCheck(checks, config, client) {
|
|
84
|
+
try {
|
|
85
|
+
const lockStatus = await readRemoteLockStatus(config, client);
|
|
86
|
+
if (!lockStatus.locked) {
|
|
87
|
+
checks.push({
|
|
88
|
+
name: '远端锁',
|
|
89
|
+
status: 'pass',
|
|
90
|
+
message: '没有发现发布或回滚锁',
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
checks.push({
|
|
95
|
+
name: '远端锁',
|
|
96
|
+
status: 'warn',
|
|
97
|
+
message: [
|
|
98
|
+
`发现远端锁: ${lockStatus.lockPath}`,
|
|
99
|
+
`pid: ${lockStatus.pid ?? '未知'}`,
|
|
100
|
+
`创建时间: ${lockStatus.createdAt ?? '未知'}`,
|
|
101
|
+
'确认没有发布或回滚任务后再手动删除该目录',
|
|
102
|
+
].join(';'),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
checks.push({
|
|
107
|
+
name: '远端锁',
|
|
108
|
+
status: 'fail',
|
|
109
|
+
message: error instanceof Error ? error.message : String(error),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
81
113
|
async function addSourcePathCheck(checks, sourcePath) {
|
|
82
114
|
try {
|
|
83
115
|
await access(sourcePath);
|
package/dist/lock.js
CHANGED
|
@@ -3,6 +3,25 @@ const remoteLockDir = '.ssh-release.lock';
|
|
|
3
3
|
export function getRemoteLockPath(config) {
|
|
4
4
|
return remoteJoin(config.target.path, remoteLockDir);
|
|
5
5
|
}
|
|
6
|
+
export async function readRemoteLockStatus(config, client) {
|
|
7
|
+
const lockPath = getRemoteLockPath(config);
|
|
8
|
+
const pidPath = remoteJoin(lockPath, 'pid');
|
|
9
|
+
const createdAtPath = remoteJoin(lockPath, 'created_at');
|
|
10
|
+
const result = await client.exec(`if [ -d ${shellQuote(lockPath)} ]; then echo locked; printf 'pid='; cat ${shellQuote(pidPath)} 2>/dev/null || true; printf '\\ncreated_at='; cat ${shellQuote(createdAtPath)} 2>/dev/null || true; printf '\\n'; else echo unlocked; fi`);
|
|
11
|
+
const lines = result.stdout.split('\n').map((line) => line.trim());
|
|
12
|
+
if (lines[0] !== 'locked') {
|
|
13
|
+
return {
|
|
14
|
+
locked: false,
|
|
15
|
+
lockPath,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
locked: true,
|
|
20
|
+
lockPath,
|
|
21
|
+
pid: parseRemoteLockValue(lines, 'pid'),
|
|
22
|
+
createdAt: parseRemoteLockValue(lines, 'created_at'),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
6
25
|
export async function acquireRemoteLock(config, client, options = {}) {
|
|
7
26
|
const createTargetPath = options.createTargetPath ?? true;
|
|
8
27
|
const lockPath = getRemoteLockPath(config);
|
|
@@ -19,3 +38,12 @@ export async function acquireRemoteLock(config, client, options = {}) {
|
|
|
19
38
|
await client.exec(`rm -rf ${shellQuote(lockPath)}`);
|
|
20
39
|
};
|
|
21
40
|
}
|
|
41
|
+
export async function removeRemoteLock(config, client) {
|
|
42
|
+
await client.exec(`rm -rf ${shellQuote(getRemoteLockPath(config))}`);
|
|
43
|
+
}
|
|
44
|
+
function parseRemoteLockValue(lines, fieldName) {
|
|
45
|
+
const prefix = `${fieldName}=`;
|
|
46
|
+
const line = lines.find((entry) => entry.startsWith(prefix));
|
|
47
|
+
const value = line?.slice(prefix.length).trim();
|
|
48
|
+
return value || undefined;
|
|
49
|
+
}
|
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
|
+
}
|