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 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 连接、远程目录和远端 `tar`。
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 handlers = options.handlers ?? createDefaultHandlers();
20
- const [command, ...args] = argv;
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('已创建 ssh-release.config.ts');
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.log(`用法:
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 createDefaultHandlers() {
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
- init: () => writeConfigTemplate(),
60
- deploy: async () => {
61
- const config = await loadConfigFile();
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(CONFIG_FILE_NAME, createRemoteClient);
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 releasePackage = await packageFactory({
34
- sourcePath: config.source.path,
35
- exclude: config.source.exclude,
36
- versionName,
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
- return await deployOverwrite(config, client, releasePackage, versionName);
46
+ result = await deployOverwrite(config, client, releasePackage, versionName);
47
+ return result;
41
48
  }
42
- return await deployRelease(config, client, releasePackage, versionName);
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
- await releasePackage.cleanup();
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 releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
24
- const currentSymlinkPath = remoteJoin(config.target.path, config.target.currentSymlink);
25
- const releases = await readRemoteReleaseNames(client, releasesPath);
26
- const currentVersion = await readCurrentVersion(client, currentSymlinkPath);
27
- if (!currentVersion) {
28
- throw new Error('当前版本不存在');
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
- const targetVersion = selectRollbackTarget({
31
- releases,
32
- currentVersion,
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ssh-release",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "A general-purpose SSH file release CLI.",
5
5
  "type": "module",
6
6
  "bin": {