ssh-release 0.1.0 → 0.3.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 +16 -0
- package/README.md +35 -1
- package/dist/cli.js +115 -19
- package/dist/doctor.js +32 -0
- package/dist/lock.js +46 -0
- package/dist/release.js +63 -8
- package/dist/rollback.js +45 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 - 2026-06-25
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- 增加 `ssh-release doctor` 远端锁状态检查,无锁时通过,有锁时提示锁路径、pid、创建时间和安全清理条件。
|
|
8
|
+
|
|
9
|
+
## 0.2.0 - 2026-06-25
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- 增加 `--help` 和 `--version`,方便安装后确认 CLI 能力和版本。
|
|
14
|
+
- 增加 `--config <path>`,支持使用非默认配置文件。
|
|
15
|
+
- 增加 `ssh-release deploy --dry-run`,可在不连接远端、不修改服务器的情况下查看发布计划。
|
|
16
|
+
- 增加远端锁 `.ssh-release.lock`,避免同一目标目录并发发布或回滚。
|
|
17
|
+
- 增加 GitHub Actions CI,在 `main` 推送和 Pull Request 上运行安装、类型检查、测试和构建。
|
|
18
|
+
|
|
3
19
|
## 0.1.0 - 2026-06-25
|
|
4
20
|
|
|
5
21
|
首个 npm 版本。
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
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]`:回滚到上一个版本或指定版本。
|
|
@@ -40,6 +40,13 @@ npm install -g ssh-release
|
|
|
40
40
|
ssh-release init
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
查看帮助和版本:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
ssh-release --help
|
|
47
|
+
ssh-release --version
|
|
48
|
+
```
|
|
49
|
+
|
|
43
50
|
## 本地开发
|
|
44
51
|
|
|
45
52
|
```bash
|
|
@@ -78,6 +85,14 @@ npm run dev -- init
|
|
|
78
85
|
|
|
79
86
|
生成的配置文件名为 `ssh-release.config.ts`。
|
|
80
87
|
|
|
88
|
+
需要使用自定义配置路径时,可以传入 `--config`:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
ssh-release init --config deploy.config.ts
|
|
92
|
+
ssh-release doctor --config deploy.config.ts
|
|
93
|
+
ssh-release deploy --config deploy.config.ts
|
|
94
|
+
```
|
|
95
|
+
|
|
81
96
|
配置文件应使用 `export default` 导出配置对象。当前模板支持从环境变量读取服务器地址和用户名:
|
|
82
97
|
|
|
83
98
|
示例配置:
|
|
@@ -130,11 +145,14 @@ source files -> package -> upload -> release -> activate -> rollback
|
|
|
130
145
|
├── releases/
|
|
131
146
|
│ ├── 20260625-153000/
|
|
132
147
|
│ └── 20260625-150000/
|
|
148
|
+
├── .ssh-release.lock/
|
|
133
149
|
└── .ssh-release-tmp/
|
|
134
150
|
```
|
|
135
151
|
|
|
136
152
|
规则:
|
|
137
153
|
|
|
154
|
+
- 发布和回滚开始前会创建 `.ssh-release.lock`,避免同一目标目录并发修改。
|
|
155
|
+
- 发布或回滚结束后会自动删除 `.ssh-release.lock`。
|
|
138
156
|
- 每次发布创建一个新版本目录。
|
|
139
157
|
- 新版本完整上传和解压完成后,才切换 `current`。
|
|
140
158
|
- 回滚只切换 `current`,不删除版本目录。
|
|
@@ -150,6 +168,14 @@ source files -> package -> upload -> release -> activate -> rollback
|
|
|
150
168
|
ssh-release doctor
|
|
151
169
|
```
|
|
152
170
|
|
|
171
|
+
`doctor` 会检查 `.ssh-release.lock`。没有锁时正常通过;如果发现锁,会以 `warn` 显示锁路径、pid、创建时间和安全清理提示。
|
|
172
|
+
|
|
173
|
+
需要先查看发布计划但不修改服务器时:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
ssh-release deploy --dry-run
|
|
177
|
+
```
|
|
178
|
+
|
|
153
179
|
执行发布:
|
|
154
180
|
|
|
155
181
|
```bash
|
|
@@ -158,6 +184,8 @@ ssh-release deploy
|
|
|
158
184
|
|
|
159
185
|
`release` 模式发布成功后会输出版本号、版本目录和 `current` 软链接路径。只有压缩包上传和解压完成后才切换 `current`。
|
|
160
186
|
|
|
187
|
+
如果远端已有 `.ssh-release.lock`,说明同一目标目录可能正在发布或回滚,工具会停止在修改远端状态之前。确认没有任务运行后,可以手动删除 `target.path/.ssh-release.lock` 再重试。
|
|
188
|
+
|
|
161
189
|
远端 `tar` 不可用或解压失败时,如果 `deploy.fallbackToFileUpload` 为 `true`,工具会回退逐文件上传。`release` 模式只清理失败的新版本目录;`overwrite` 模式不会先删除整个目标目录。
|
|
162
190
|
|
|
163
191
|
查看版本:
|
|
@@ -239,6 +267,7 @@ npm run build
|
|
|
239
267
|
- `npm run lint`:运行 TypeScript 静态检查。
|
|
240
268
|
- `npm test`:运行 Node.js 测试。
|
|
241
269
|
- `npm run build`:编译 `dist/`。
|
|
270
|
+
- GitHub Actions 会在 `main` 推送和 Pull Request 上运行 `npm ci`、`lint`、`test` 和 `build`。
|
|
242
271
|
|
|
243
272
|
## 发布前检查
|
|
244
273
|
|
|
@@ -270,10 +299,13 @@ npm install -g "$PACK_DIR"/ssh-release-"$VERSION".tgz --prefix "$PACK_DIR/prefix
|
|
|
270
299
|
|
|
271
300
|
```text
|
|
272
301
|
CHANGELOG.md
|
|
302
|
+
.github/
|
|
303
|
+
└── workflows/ci.yml
|
|
273
304
|
src/
|
|
274
305
|
├── cli.ts
|
|
275
306
|
├── config.ts
|
|
276
307
|
├── doctor.ts
|
|
308
|
+
├── lock.ts
|
|
277
309
|
├── list.ts
|
|
278
310
|
├── package.ts
|
|
279
311
|
├── process.ts
|
|
@@ -316,6 +348,8 @@ docs/
|
|
|
316
348
|
- 本地 `.tgz` 打包和排除项。
|
|
317
349
|
- macOS AppleDouble 元数据排除。
|
|
318
350
|
- `release` 和 `overwrite` 发布流程。
|
|
351
|
+
- 发布和回滚锁获取、释放和锁冲突拦截。
|
|
352
|
+
- `doctor` 远端锁状态检查和安全清理提示。
|
|
319
353
|
- 远端 `tar` 失败后的逐文件上传回退。
|
|
320
354
|
- 远程版本列表读取和当前版本标记。
|
|
321
355
|
- `doctor` 检查结果。
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
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 { 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
|
export function isCliEntrypoint(moduleUrl, argvEntry) {
|
|
@@ -16,16 +16,30 @@ export function isCliEntrypoint(moduleUrl, argvEntry) {
|
|
|
16
16
|
}
|
|
17
17
|
export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
18
18
|
const io = options.io ?? console;
|
|
19
|
-
const
|
|
20
|
-
|
|
19
|
+
const parsed = parseCliArgs(argv);
|
|
20
|
+
if (parsed.error) {
|
|
21
|
+
io.error(parsed.error);
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
if (parsed.help) {
|
|
25
|
+
printUsage(io);
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
if (parsed.version) {
|
|
29
|
+
io.log(readPackageVersion());
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
const handlers = options.handlers ?? createDefaultHandlers(parsed.configPath);
|
|
33
|
+
const command = parsed.command;
|
|
34
|
+
const args = parsed.args;
|
|
21
35
|
try {
|
|
22
36
|
if (command === 'init') {
|
|
23
37
|
await handlers.init();
|
|
24
|
-
io.log(
|
|
38
|
+
io.log(`已创建 ${parsed.configPath}`);
|
|
25
39
|
return 0;
|
|
26
40
|
}
|
|
27
41
|
if (command === 'deploy') {
|
|
28
|
-
printDeployResult(await handlers.deploy(), io);
|
|
42
|
+
printDeployResult(await handlers.deploy({ dryRun: parsed.dryRun }), io);
|
|
29
43
|
return 0;
|
|
30
44
|
}
|
|
31
45
|
if (command === 'rollback') {
|
|
@@ -41,12 +55,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
41
55
|
printDoctorReport(report, io);
|
|
42
56
|
return report.ok ? 0 : 1;
|
|
43
57
|
}
|
|
44
|
-
io
|
|
45
|
-
ssh-release init
|
|
46
|
-
ssh-release deploy
|
|
47
|
-
ssh-release rollback [version]
|
|
48
|
-
ssh-release list
|
|
49
|
-
ssh-release doctor`);
|
|
58
|
+
printUsage(io);
|
|
50
59
|
return command ? 1 : 0;
|
|
51
60
|
}
|
|
52
61
|
catch (error) {
|
|
@@ -54,27 +63,96 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
54
63
|
return 1;
|
|
55
64
|
}
|
|
56
65
|
}
|
|
57
|
-
function
|
|
66
|
+
function parseCliArgs(argv) {
|
|
67
|
+
const args = [];
|
|
68
|
+
let configPath = CONFIG_FILE_NAME;
|
|
69
|
+
let dryRun = false;
|
|
70
|
+
let help = false;
|
|
71
|
+
let version = false;
|
|
72
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
73
|
+
const arg = argv[index];
|
|
74
|
+
if (arg === '--help' || arg === '-h') {
|
|
75
|
+
help = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (arg === '--version' || arg === '-v') {
|
|
79
|
+
version = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (arg === '--config' || arg === '-c') {
|
|
83
|
+
const value = argv[index + 1];
|
|
84
|
+
if (!value || value.startsWith('-')) {
|
|
85
|
+
return createParsedError('--config 需要配置文件路径', configPath);
|
|
86
|
+
}
|
|
87
|
+
configPath = value;
|
|
88
|
+
index += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (arg === '--dry-run') {
|
|
92
|
+
dryRun = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (arg.startsWith('-')) {
|
|
96
|
+
return createParsedError(`未知选项: ${arg}`, configPath);
|
|
97
|
+
}
|
|
98
|
+
args.push(arg);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
args: args.slice(1),
|
|
102
|
+
command: args[0],
|
|
103
|
+
configPath,
|
|
104
|
+
dryRun,
|
|
105
|
+
help,
|
|
106
|
+
version,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function createParsedError(error, configPath) {
|
|
58
110
|
return {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
111
|
+
args: [],
|
|
112
|
+
configPath,
|
|
113
|
+
dryRun: false,
|
|
114
|
+
error,
|
|
115
|
+
help: false,
|
|
116
|
+
version: false,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function createDefaultHandlers(configPath) {
|
|
120
|
+
return {
|
|
121
|
+
init: () => writeConfigTemplate(configPath),
|
|
122
|
+
deploy: async (options) => {
|
|
123
|
+
const config = await loadConfigFile(configPath);
|
|
124
|
+
if (options?.dryRun) {
|
|
125
|
+
return createDeployPlan(config);
|
|
126
|
+
}
|
|
62
127
|
return deploy(config, createRemoteClient(config));
|
|
63
128
|
},
|
|
64
129
|
rollback: async (version) => {
|
|
65
|
-
const config = await loadConfigFile();
|
|
130
|
+
const config = await loadConfigFile(configPath);
|
|
66
131
|
return rollback(config, createRemoteClient(config), version);
|
|
67
132
|
},
|
|
68
133
|
list: async () => {
|
|
69
|
-
const config = await loadConfigFile();
|
|
134
|
+
const config = await loadConfigFile(configPath);
|
|
70
135
|
return listReleases(config, createRemoteClient(config));
|
|
71
136
|
},
|
|
72
137
|
doctor: async () => {
|
|
73
|
-
return runDoctorFromFile(
|
|
138
|
+
return runDoctorFromFile(configPath, createRemoteClient);
|
|
74
139
|
},
|
|
75
140
|
};
|
|
76
141
|
}
|
|
77
142
|
function printDeployResult(result, io) {
|
|
143
|
+
if ('dryRun' in result) {
|
|
144
|
+
io.log('发布预检通过,不会修改远程服务器');
|
|
145
|
+
io.log(`模式: ${result.mode}`);
|
|
146
|
+
io.log(`源路径: ${result.sourcePath}`);
|
|
147
|
+
io.log(`目标目录: ${result.targetPath}`);
|
|
148
|
+
if (result.version) {
|
|
149
|
+
io.log(`计划版本: ${result.version}`);
|
|
150
|
+
}
|
|
151
|
+
if (result.currentSymlink) {
|
|
152
|
+
io.log(`计划切换: ${result.currentSymlink}`);
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
78
156
|
if (result.mode === 'release') {
|
|
79
157
|
io.log(`发布成功: ${result.version}`);
|
|
80
158
|
io.log(`版本目录: ${result.targetPath}`);
|
|
@@ -94,6 +172,9 @@ function printDeployResult(result, io) {
|
|
|
94
172
|
function printRollbackResult(result, io) {
|
|
95
173
|
io.log(`已回滚到版本: ${result.version}`);
|
|
96
174
|
io.log(`当前指向: ${result.currentSymlink}`);
|
|
175
|
+
for (const warning of result.warnings) {
|
|
176
|
+
io.log(`警告: ${warning}`);
|
|
177
|
+
}
|
|
97
178
|
}
|
|
98
179
|
function printListResult(result, io) {
|
|
99
180
|
io.log(`远程目录: ${result.targetPath}`);
|
|
@@ -120,6 +201,21 @@ function toRealPath(filePath) {
|
|
|
120
201
|
return resolve(filePath);
|
|
121
202
|
}
|
|
122
203
|
}
|
|
204
|
+
function printUsage(io) {
|
|
205
|
+
io.log(`用法:
|
|
206
|
+
ssh-release init [--config <path>]
|
|
207
|
+
ssh-release doctor [--config <path>]
|
|
208
|
+
ssh-release deploy [--config <path>]
|
|
209
|
+
ssh-release deploy --dry-run [--config <path>]
|
|
210
|
+
ssh-release list [--config <path>]
|
|
211
|
+
ssh-release rollback [version] [--config <path>]
|
|
212
|
+
ssh-release --help
|
|
213
|
+
ssh-release --version`);
|
|
214
|
+
}
|
|
215
|
+
function readPackageVersion() {
|
|
216
|
+
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
217
|
+
return packageJson.version ?? '0.0.0';
|
|
218
|
+
}
|
|
123
219
|
if (isCliEntrypoint(import.meta.url, process.argv[1])) {
|
|
124
220
|
const exitCode = await runCli();
|
|
125
221
|
process.exit(exitCode);
|
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
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { remoteJoin, shellQuote } from './remote.js';
|
|
2
|
+
const remoteLockDir = '.ssh-release.lock';
|
|
3
|
+
export function getRemoteLockPath(config) {
|
|
4
|
+
return remoteJoin(config.target.path, remoteLockDir);
|
|
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
|
+
}
|
|
25
|
+
export async function acquireRemoteLock(config, client, options = {}) {
|
|
26
|
+
const createTargetPath = options.createTargetPath ?? true;
|
|
27
|
+
const lockPath = getRemoteLockPath(config);
|
|
28
|
+
const createdAtPath = remoteJoin(lockPath, 'created_at');
|
|
29
|
+
const pidPath = remoteJoin(lockPath, 'pid');
|
|
30
|
+
const lockedMessage = `远程已有发布任务正在运行,请确认后删除 ${lockPath}`;
|
|
31
|
+
const missingTargetMessage = `远程目标目录不存在: ${config.target.path}`;
|
|
32
|
+
const prepareTarget = createTargetPath
|
|
33
|
+
? `mkdir -p ${shellQuote(config.target.path)}`
|
|
34
|
+
: `if [ -d ${shellQuote(config.target.path)} ]; then :; else echo ${shellQuote(missingTargetMessage)} >&2; exit 74; fi`;
|
|
35
|
+
const command = `${prepareTarget} && if mkdir ${shellQuote(lockPath)} 2>/dev/null; then printf '%s\\n' "$$" > ${shellQuote(pidPath)}; date -u '+%Y-%m-%dT%H:%M:%SZ' > ${shellQuote(createdAtPath)}; else echo ${shellQuote(lockedMessage)} >&2; exit 73; fi`;
|
|
36
|
+
await client.exec(command);
|
|
37
|
+
return async () => {
|
|
38
|
+
await client.exec(`rm -rf ${shellQuote(lockPath)}`);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function parseRemoteLockValue(lines, fieldName) {
|
|
42
|
+
const prefix = `${fieldName}=`;
|
|
43
|
+
const line = lines.find((entry) => entry.startsWith(prefix));
|
|
44
|
+
const value = line?.slice(prefix.length).trim();
|
|
45
|
+
return value || undefined;
|
|
46
|
+
}
|
package/dist/release.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { access } from 'node:fs/promises';
|
|
2
2
|
import { createReleasePackage, } from './package.js';
|
|
3
|
+
import { acquireRemoteLock } from './lock.js';
|
|
3
4
|
import { readRemoteReleaseNames, remoteJoin, shellQuote, } from './remote.js';
|
|
4
5
|
export function createVersionName(date = new Date()) {
|
|
5
6
|
return [
|
|
@@ -30,21 +31,75 @@ export async function deploy(config, client, options = {}) {
|
|
|
30
31
|
await ensureSourceExists(config.source.path);
|
|
31
32
|
const versionName = createVersionName(options.now ?? new Date());
|
|
32
33
|
const packageFactory = options.createPackage ?? createReleasePackage;
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
const releaseLock = await acquireRemoteLock(config, client);
|
|
35
|
+
let releasePackage;
|
|
36
|
+
let result;
|
|
37
|
+
let deployError;
|
|
38
|
+
let cleanupError;
|
|
38
39
|
try {
|
|
40
|
+
releasePackage = await packageFactory({
|
|
41
|
+
sourcePath: config.source.path,
|
|
42
|
+
exclude: config.source.exclude,
|
|
43
|
+
versionName,
|
|
44
|
+
});
|
|
39
45
|
if (config.deploy.mode === 'overwrite') {
|
|
40
|
-
|
|
46
|
+
result = await deployOverwrite(config, client, releasePackage, versionName);
|
|
47
|
+
return result;
|
|
41
48
|
}
|
|
42
|
-
|
|
49
|
+
result = await deployRelease(config, client, releasePackage, versionName);
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
deployError = error;
|
|
54
|
+
throw error;
|
|
43
55
|
}
|
|
44
56
|
finally {
|
|
45
|
-
|
|
57
|
+
if (releasePackage) {
|
|
58
|
+
try {
|
|
59
|
+
await releasePackage.cleanup();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
cleanupError = error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await releaseLock();
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const message = `远程发布锁清理失败: ${formatError(error)}`;
|
|
70
|
+
if (result) {
|
|
71
|
+
result.warnings.push(message);
|
|
72
|
+
}
|
|
73
|
+
else if (!deployError && !cleanupError) {
|
|
74
|
+
cleanupError = error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (cleanupError && !deployError) {
|
|
78
|
+
throw cleanupError;
|
|
79
|
+
}
|
|
46
80
|
}
|
|
47
81
|
}
|
|
82
|
+
export async function createDeployPlan(config, options = {}) {
|
|
83
|
+
await ensureSourceExists(config.source.path);
|
|
84
|
+
if (config.deploy.mode === 'overwrite') {
|
|
85
|
+
return {
|
|
86
|
+
dryRun: true,
|
|
87
|
+
mode: 'overwrite',
|
|
88
|
+
sourcePath: config.source.path,
|
|
89
|
+
targetPath: config.target.path,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const versionName = createVersionName(options.now ?? new Date());
|
|
93
|
+
const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
|
|
94
|
+
return {
|
|
95
|
+
dryRun: true,
|
|
96
|
+
mode: 'release',
|
|
97
|
+
version: versionName,
|
|
98
|
+
sourcePath: config.source.path,
|
|
99
|
+
targetPath: remoteJoin(releasesPath, versionName),
|
|
100
|
+
currentSymlink: remoteJoin(config.target.path, config.target.currentSymlink),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
48
103
|
async function deployRelease(config, client, releasePackage, versionName) {
|
|
49
104
|
const warnings = [];
|
|
50
105
|
const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
|
package/dist/rollback.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readCurrentVersion, readRemoteReleaseNames, remoteJoin, shellQuote, } from './remote.js';
|
|
2
|
+
import { acquireRemoteLock } from './lock.js';
|
|
2
3
|
export function selectRollbackTarget(selection) {
|
|
3
4
|
const releases = [...new Set(selection.releases)].sort();
|
|
4
5
|
if (selection.requestedVersion) {
|
|
@@ -20,21 +21,49 @@ export async function rollback(config, client, requestedVersion) {
|
|
|
20
21
|
if (config.deploy.mode === 'overwrite') {
|
|
21
22
|
throw new Error('overwrite 模式不支持回滚');
|
|
22
23
|
}
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
const releaseLock = await acquireRemoteLock(config, client, { createTargetPath: false });
|
|
25
|
+
let result;
|
|
26
|
+
let rollbackError;
|
|
27
|
+
try {
|
|
28
|
+
const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
|
|
29
|
+
const currentSymlinkPath = remoteJoin(config.target.path, config.target.currentSymlink);
|
|
30
|
+
const releases = await readRemoteReleaseNames(client, releasesPath);
|
|
31
|
+
const currentVersion = await readCurrentVersion(client, currentSymlinkPath);
|
|
32
|
+
if (!currentVersion) {
|
|
33
|
+
throw new Error('当前版本不存在');
|
|
34
|
+
}
|
|
35
|
+
const targetVersion = selectRollbackTarget({
|
|
36
|
+
releases,
|
|
37
|
+
currentVersion,
|
|
38
|
+
requestedVersion,
|
|
39
|
+
});
|
|
40
|
+
await client.exec(`ln -sfn ${shellQuote(remoteJoin(config.target.releasesDir, targetVersion))} ${shellQuote(currentSymlinkPath)}`);
|
|
41
|
+
result = {
|
|
42
|
+
version: targetVersion,
|
|
43
|
+
currentSymlink: currentSymlinkPath,
|
|
44
|
+
warnings: [],
|
|
45
|
+
};
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
rollbackError = error;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
try {
|
|
54
|
+
await releaseLock();
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
const message = `远程发布锁清理失败: ${formatError(error)}`;
|
|
58
|
+
if (result) {
|
|
59
|
+
result.warnings.push(message);
|
|
60
|
+
}
|
|
61
|
+
else if (!rollbackError) {
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
29
65
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
requestedVersion,
|
|
34
|
-
});
|
|
35
|
-
await client.exec(`ln -sfn ${shellQuote(remoteJoin(config.target.releasesDir, targetVersion))} ${shellQuote(currentSymlinkPath)}`);
|
|
36
|
-
return {
|
|
37
|
-
version: targetVersion,
|
|
38
|
-
currentSymlink: currentSymlinkPath,
|
|
39
|
-
};
|
|
66
|
+
}
|
|
67
|
+
function formatError(error) {
|
|
68
|
+
return error instanceof Error ? error.message : String(error);
|
|
40
69
|
}
|