ssh-release 0.1.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 ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-06-25
4
+
5
+ 首个 npm 版本。
6
+
7
+ ### Added
8
+
9
+ - 提供 `ssh-release init` 生成配置模板。
10
+ - 提供 `ssh-release doctor` 检查配置、本地源路径、SSH 连接、远程目录和远端 `tar`。
11
+ - 提供 `ssh-release deploy` 发布本地文件或目录。
12
+ - 提供 `ssh-release list` 查看远程版本和当前版本。
13
+ - 提供 `ssh-release rollback [version]` 回滚到上一个版本或指定版本。
14
+ - 支持 `release` 模式:上传压缩包、远端解压、切换 `current`、清理旧版本。
15
+ - 支持 `overwrite` 模式:直接覆盖发布到目标目录。
16
+ - 支持远端 `tar` 失败后逐文件上传回退。
17
+ - 支持私钥登录和密码登录;密码通过 `SSHPASS` 环境变量交给 `sshpass -e`。
18
+ - 拦截危险远程目标路径和不安全的目标辅助目录名。
19
+ - macOS 打包时排除 AppleDouble 和扩展属性元数据。
20
+
21
+ ### Verified
22
+
23
+ - 通过单元测试和 fake `ssh`/`scp` 端到端测试覆盖核心发布、列表、回滚和覆盖发布流程。
24
+ - 通过真实服务器验证密码登录、`doctor`、`deploy`、`list`、`rollback` 和 `overwrite` 流程。
25
+ - 通过 `npm publish --dry-run` 和本地 tarball 安装烟测验证 npm 包内容和 CLI 入口。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,337 @@
1
+ # ssh-release
2
+
3
+ `ssh-release` 是一个通用 SSH 文件发布 CLI,目标是把本地文件或目录可靠发布到服务器。
4
+
5
+ 它只负责文件发布和版本切换,不负责构建项目、重启服务、修改 Nginx、操作容器或执行自定义远程脚本。
6
+
7
+ ## 当前状态
8
+
9
+ 当前仓库已完成第一版 MVP。
10
+
11
+ 已实现:
12
+
13
+ - `ssh-release init`:生成 `ssh-release.config.ts` 配置模板。
14
+ - `ssh-release doctor`:检查配置、本地源路径、SSH 连接、远程目录和远端 `tar`。
15
+ - `ssh-release deploy`:发布本地文件或目录。
16
+ - `ssh-release list`:查看远程版本和当前版本。
17
+ - `ssh-release rollback [version]`:回滚到上一个版本或指定版本。
18
+ - `release` 模式:上传压缩包、远端解压、切换 `current`、清理旧版本。
19
+ - `overwrite` 模式:直接覆盖发布到目标目录。
20
+ - 远端 `tar` 解压失败时回退逐文件上传。
21
+ - 配置字段归一化、危险远程路径拦截、版本号生成、旧版本保留计算和回滚目标选择。
22
+
23
+ ## 安装依赖
24
+
25
+ ```bash
26
+ npm install
27
+ ```
28
+
29
+ ## 全局安装
30
+
31
+ 发布到 npm 后,可以在任意项目中全局安装:
32
+
33
+ ```bash
34
+ npm install -g ssh-release
35
+ ```
36
+
37
+ 安装后在需要发布文件的项目目录中执行:
38
+
39
+ ```bash
40
+ ssh-release init
41
+ ```
42
+
43
+ ## 本地开发
44
+
45
+ ```bash
46
+ npm run dev -- init
47
+ npm run dev -- doctor
48
+ npm run dev -- deploy
49
+ npm run dev -- list
50
+ npm run dev -- rollback
51
+ ```
52
+
53
+ 构建 CLI:
54
+
55
+ ```bash
56
+ npm run build
57
+ ```
58
+
59
+ 构建后可以直接运行:
60
+
61
+ ```bash
62
+ node dist/cli.js init
63
+ ```
64
+
65
+ ## 配置初始化
66
+
67
+ 在需要发布文件的项目目录中执行:
68
+
69
+ ```bash
70
+ ssh-release init
71
+ ```
72
+
73
+ 当前开发阶段也可以用源码入口执行:
74
+
75
+ ```bash
76
+ npm run dev -- init
77
+ ```
78
+
79
+ 生成的配置文件名为 `ssh-release.config.ts`。
80
+
81
+ 配置文件应使用 `export default` 导出配置对象。当前模板支持从环境变量读取服务器地址和用户名:
82
+
83
+ 示例配置:
84
+
85
+ ```ts
86
+ export default {
87
+ source: {
88
+ path: './dist',
89
+ exclude: ['.DS_Store', 'node_modules'],
90
+ },
91
+
92
+ server: {
93
+ host: process.env.SSH_RELEASE_HOST,
94
+ port: 22,
95
+ username: process.env.SSH_RELEASE_USER,
96
+ password: process.env.SSH_RELEASE_PASSWORD,
97
+ privateKeyPath: '~/.ssh/id_rsa',
98
+ },
99
+
100
+ target: {
101
+ path: '/var/www/my-app',
102
+ currentSymlink: 'current',
103
+ releasesDir: 'releases',
104
+ tempDir: '.ssh-release-tmp',
105
+ },
106
+
107
+ deploy: {
108
+ mode: 'release',
109
+ keepReleases: 5,
110
+ compression: 'tgz',
111
+ preferTar: true,
112
+ fallbackToFileUpload: true,
113
+ },
114
+ };
115
+ ```
116
+
117
+ ## 发布模型
118
+
119
+ 默认发布模型是版本化发布:
120
+
121
+ ```text
122
+ source files -> package -> upload -> release -> activate -> rollback
123
+ ```
124
+
125
+ `release` 模式下,远程目录规划如下:
126
+
127
+ ```text
128
+ /var/www/my-app/
129
+ ├── current -> releases/20260625-153000
130
+ ├── releases/
131
+ │ ├── 20260625-153000/
132
+ │ └── 20260625-150000/
133
+ └── .ssh-release-tmp/
134
+ ```
135
+
136
+ 规则:
137
+
138
+ - 每次发布创建一个新版本目录。
139
+ - 新版本完整上传和解压完成后,才切换 `current`。
140
+ - 回滚只切换 `current`,不删除版本目录。
141
+ - 清理旧版本时保留当前版本。
142
+
143
+ `overwrite` 模式用于不需要版本管理的目录,会直接发布到 `target.path`,不创建 `current` 和 `releases`。
144
+
145
+ ## 发布命令
146
+
147
+ 发布前先检查配置和远端环境:
148
+
149
+ ```bash
150
+ ssh-release doctor
151
+ ```
152
+
153
+ 执行发布:
154
+
155
+ ```bash
156
+ ssh-release deploy
157
+ ```
158
+
159
+ `release` 模式发布成功后会输出版本号、版本目录和 `current` 软链接路径。只有压缩包上传和解压完成后才切换 `current`。
160
+
161
+ 远端 `tar` 不可用或解压失败时,如果 `deploy.fallbackToFileUpload` 为 `true`,工具会回退逐文件上传。`release` 模式只清理失败的新版本目录;`overwrite` 模式不会先删除整个目标目录。
162
+
163
+ 查看版本:
164
+
165
+ ```bash
166
+ ssh-release list
167
+ ```
168
+
169
+ 回滚到上一个版本:
170
+
171
+ ```bash
172
+ ssh-release rollback
173
+ ```
174
+
175
+ 回滚到指定版本:
176
+
177
+ ```bash
178
+ ssh-release rollback 20260625-150000
179
+ ```
180
+
181
+ `overwrite` 模式没有版本列表,也不支持回滚。
182
+
183
+ ## 安全边界
184
+
185
+ 配置模板不包含真实 IP、密码或生产路径。
186
+
187
+ 推荐通过环境变量提供服务器信息:
188
+
189
+ ```bash
190
+ export SSH_RELEASE_HOST=example.com
191
+ export SSH_RELEASE_USER=deploy
192
+ export SSH_RELEASE_PASSWORD='your-password'
193
+ ```
194
+
195
+ 认证方式支持私钥和密码:
196
+
197
+ - 设置 `server.privateKeyPath` 时使用私钥登录。
198
+ - 设置 `server.password` 时使用密码登录,推荐写成 `process.env.SSH_RELEASE_PASSWORD`。
199
+ - 同时设置 `password` 和 `privateKeyPath` 时,优先使用密码登录。
200
+ - 密码不会作为命令行参数传给 `ssh` 或 `scp`,运行时通过 `SSHPASS` 环境变量交给 `sshpass -e`。
201
+ - 不要把真实密码写进 `ssh-release.config.ts` 或仓库文件。
202
+
203
+ 以下远程目标路径会被默认拒绝:
204
+
205
+ - `/`
206
+ - `/home`
207
+ - `/root`
208
+ - `/var`
209
+ - `/usr`
210
+ - `/etc`
211
+ - `/opt`
212
+ - `/tmp`
213
+
214
+ 这些路径下的子目录可以使用,例如 `/var/www/my-app`。
215
+
216
+ `target.currentSymlink`、`target.releasesDir` 和 `target.tempDir` 必须是简单相对名称,不能包含 `/`、`\`,也不能等于 `.` 或 `..`。
217
+
218
+ 本地需要可用的系统命令:
219
+
220
+ - `tar`:本地打包发布内容。
221
+ - `ssh`:执行远端目录、解压、软链接、列表和检查命令。
222
+ - `scp`:上传压缩包和逐文件回退上传。
223
+ - `sshpass`:仅密码登录时需要;私钥登录不需要。
224
+
225
+ 在 macOS 上打包时会禁用 AppleDouble 和扩展属性元数据,避免把 `._*` 文件发布到 Linux 服务器。
226
+
227
+ 远端 `tar` 是可选能力,不可用时会按配置回退逐文件上传。
228
+
229
+ ## 开发命令
230
+
231
+ ```bash
232
+ npm run lint
233
+ npm test
234
+ npm run build
235
+ ```
236
+
237
+ 命令说明:
238
+
239
+ - `npm run lint`:运行 TypeScript 静态检查。
240
+ - `npm test`:运行 Node.js 测试。
241
+ - `npm run build`:编译 `dist/`。
242
+
243
+ ## 发布前检查
244
+
245
+ 发布前先运行完整校验:
246
+
247
+ ```bash
248
+ npm run prepublishOnly
249
+ npm publish --dry-run
250
+ ```
251
+
252
+ 需要验证 npm 安装后的真实命令入口时,可以使用本地 tarball 做烟测:
253
+
254
+ ```bash
255
+ PACK_DIR=$(mktemp -d)
256
+ VERSION=$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")
257
+ npm pack --pack-destination "$PACK_DIR"
258
+ npm install -g "$PACK_DIR"/ssh-release-"$VERSION".tgz --prefix "$PACK_DIR/prefix"
259
+ "$PACK_DIR/prefix/bin/ssh-release"
260
+ "$PACK_DIR/prefix/bin/ssh-release" init
261
+ ```
262
+
263
+ `npm publish` 会发布到 npm registry,只有确认版本号、包内容、登录账号和发布权限都正确后再执行。
264
+
265
+ 完整发布步骤见 [docs/release-checklist.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/release-checklist.md)。
266
+
267
+ 版本变更见 [CHANGELOG.md](https://github.com/JackEngineer/ssh-release/blob/main/CHANGELOG.md)。
268
+
269
+ ## 项目结构
270
+
271
+ ```text
272
+ CHANGELOG.md
273
+ src/
274
+ ├── cli.ts
275
+ ├── config.ts
276
+ ├── doctor.ts
277
+ ├── list.ts
278
+ ├── package.ts
279
+ ├── process.ts
280
+ ├── remote.ts
281
+ ├── release.ts
282
+ ├── rollback.ts
283
+ ├── ssh.ts
284
+ ├── types.ts
285
+ └── validate.ts
286
+
287
+ tests/
288
+ ├── cli.test.ts
289
+ ├── config-load.test.ts
290
+ ├── config-template.test.ts
291
+ ├── deploy.test.ts
292
+ ├── e2e.test.ts
293
+ ├── list-doctor.test.ts
294
+ ├── package-json.test.ts
295
+ ├── package.test.ts
296
+ ├── release.test.ts
297
+ ├── rollback.test.ts
298
+ ├── ssh.test.ts
299
+ └── validate.test.ts
300
+
301
+ docs/
302
+ ├── release-checklist.md
303
+ └── superpowers/specs/2026-06-25-ssh-release-design.md
304
+ ```
305
+
306
+ ## 测试重点
307
+
308
+ 当前测试覆盖:
309
+
310
+ - 配置模板不泄露真实主机、密码和 IP。
311
+ - 配置文件加载和 `~` 路径展开。
312
+ - 配置默认值填充。
313
+ - 危险远程路径拒绝。
314
+ - 发布模式和压缩格式校验。
315
+ - CLI 命令分发。
316
+ - 本地 `.tgz` 打包和排除项。
317
+ - macOS AppleDouble 元数据排除。
318
+ - `release` 和 `overwrite` 发布流程。
319
+ - 远端 `tar` 失败后的逐文件上传回退。
320
+ - 远程版本列表读取和当前版本标记。
321
+ - `doctor` 检查结果。
322
+ - 通过临时 fake `ssh`/`scp` 进程执行端到端发布、列表、回滚和覆盖发布验收。
323
+ - 时间戳版本名生成。
324
+ - 旧版本清理选择。
325
+ - 回滚目标选择。
326
+
327
+ ## 设计文档
328
+
329
+ 完整设计见:
330
+
331
+ ```text
332
+ docs/superpowers/specs/2026-06-25-ssh-release-design.md
333
+ ```
334
+
335
+ ## License
336
+
337
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { CONFIG_FILE_NAME, loadConfigFile, writeConfigTemplate } from './config.js';
6
+ import { runDoctorFromFile } from './doctor.js';
7
+ import { listReleases } from './list.js';
8
+ import { deploy } from './release.js';
9
+ import { rollback } from './rollback.js';
10
+ import { createRemoteClient } from './ssh.js';
11
+ export function isCliEntrypoint(moduleUrl, argvEntry) {
12
+ if (!argvEntry) {
13
+ return false;
14
+ }
15
+ return toRealPath(fileURLToPath(moduleUrl)) === toRealPath(argvEntry);
16
+ }
17
+ export async function runCli(argv = process.argv.slice(2), options = {}) {
18
+ const io = options.io ?? console;
19
+ const handlers = options.handlers ?? createDefaultHandlers();
20
+ const [command, ...args] = argv;
21
+ try {
22
+ if (command === 'init') {
23
+ await handlers.init();
24
+ io.log('已创建 ssh-release.config.ts');
25
+ return 0;
26
+ }
27
+ if (command === 'deploy') {
28
+ printDeployResult(await handlers.deploy(), io);
29
+ return 0;
30
+ }
31
+ if (command === 'rollback') {
32
+ printRollbackResult(await handlers.rollback(args[0]), io);
33
+ return 0;
34
+ }
35
+ if (command === 'list') {
36
+ printListResult(await handlers.list(), io);
37
+ return 0;
38
+ }
39
+ if (command === 'doctor') {
40
+ const report = await handlers.doctor();
41
+ printDoctorReport(report, io);
42
+ return report.ok ? 0 : 1;
43
+ }
44
+ io.log(`用法:
45
+ ssh-release init
46
+ ssh-release deploy
47
+ ssh-release rollback [version]
48
+ ssh-release list
49
+ ssh-release doctor`);
50
+ return command ? 1 : 0;
51
+ }
52
+ catch (error) {
53
+ io.error(error instanceof Error ? error.message : String(error));
54
+ return 1;
55
+ }
56
+ }
57
+ function createDefaultHandlers() {
58
+ return {
59
+ init: () => writeConfigTemplate(),
60
+ deploy: async () => {
61
+ const config = await loadConfigFile();
62
+ return deploy(config, createRemoteClient(config));
63
+ },
64
+ rollback: async (version) => {
65
+ const config = await loadConfigFile();
66
+ return rollback(config, createRemoteClient(config), version);
67
+ },
68
+ list: async () => {
69
+ const config = await loadConfigFile();
70
+ return listReleases(config, createRemoteClient(config));
71
+ },
72
+ doctor: async () => {
73
+ return runDoctorFromFile(CONFIG_FILE_NAME, createRemoteClient);
74
+ },
75
+ };
76
+ }
77
+ function printDeployResult(result, io) {
78
+ if (result.mode === 'release') {
79
+ io.log(`发布成功: ${result.version}`);
80
+ io.log(`版本目录: ${result.targetPath}`);
81
+ io.log(`当前指向: ${result.currentSymlink}`);
82
+ }
83
+ else {
84
+ io.log('覆盖发布成功');
85
+ io.log(`目标目录: ${result.targetPath}`);
86
+ }
87
+ if (result.usedFallback) {
88
+ io.log('远端 tar 不可用或解压失败,已使用逐文件上传');
89
+ }
90
+ for (const warning of result.warnings) {
91
+ io.log(`警告: ${warning}`);
92
+ }
93
+ }
94
+ function printRollbackResult(result, io) {
95
+ io.log(`已回滚到版本: ${result.version}`);
96
+ io.log(`当前指向: ${result.currentSymlink}`);
97
+ }
98
+ function printListResult(result, io) {
99
+ io.log(`远程目录: ${result.targetPath}`);
100
+ if (result.mode === 'overwrite') {
101
+ io.log('overwrite 模式没有版本列表');
102
+ return;
103
+ }
104
+ io.log(`当前版本: ${result.currentVersion ?? '未设置'}`);
105
+ for (const release of result.releases) {
106
+ const marker = release.current ? '*' : ' ';
107
+ io.log(`${marker} ${release.version} ${release.modifiedAt.toISOString()}`);
108
+ }
109
+ }
110
+ function printDoctorReport(report, io) {
111
+ for (const check of report.checks) {
112
+ io.log(`[${check.status}] ${check.name}: ${check.message}`);
113
+ }
114
+ }
115
+ function toRealPath(filePath) {
116
+ try {
117
+ return realpathSync(filePath);
118
+ }
119
+ catch {
120
+ return resolve(filePath);
121
+ }
122
+ }
123
+ if (isCliEntrypoint(import.meta.url, process.argv[1])) {
124
+ const exitCode = await runCli();
125
+ process.exit(exitCode);
126
+ }
package/dist/config.js ADDED
@@ -0,0 +1,77 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { normalizeConfig } from './validate.js';
6
+ export const CONFIG_FILE_NAME = 'ssh-release.config.ts';
7
+ export function createConfigTemplate() {
8
+ return `export default {
9
+ source: {
10
+ path: './dist',
11
+ exclude: ['.DS_Store', 'node_modules'],
12
+ },
13
+
14
+ server: {
15
+ host: process.env.SSH_RELEASE_HOST,
16
+ port: 22,
17
+ username: process.env.SSH_RELEASE_USER,
18
+ password: process.env.SSH_RELEASE_PASSWORD,
19
+ privateKeyPath: '~/.ssh/id_rsa',
20
+ },
21
+
22
+ target: {
23
+ path: '/var/www/my-app',
24
+ currentSymlink: 'current',
25
+ releasesDir: 'releases',
26
+ tempDir: '.ssh-release-tmp',
27
+ },
28
+
29
+ deploy: {
30
+ mode: 'release',
31
+ keepReleases: 5,
32
+ compression: 'tgz',
33
+ preferTar: true,
34
+ fallbackToFileUpload: true,
35
+ },
36
+ };
37
+ `;
38
+ }
39
+ export async function writeConfigTemplate(configPath = CONFIG_FILE_NAME) {
40
+ await writeFile(configPath, createConfigTemplate(), { flag: 'wx' });
41
+ }
42
+ export async function loadConfigFile(configPath = CONFIG_FILE_NAME) {
43
+ const absolutePath = path.resolve(configPath);
44
+ const input = await loadConfigInput(absolutePath);
45
+ const config = normalizeConfig(input);
46
+ return {
47
+ ...config,
48
+ server: {
49
+ ...config.server,
50
+ privateKeyPath: config.server.privateKeyPath
51
+ ? resolveUserPath(config.server.privateKeyPath)
52
+ : undefined,
53
+ },
54
+ };
55
+ }
56
+ export function resolveUserPath(filePath) {
57
+ if (filePath === '~') {
58
+ return os.homedir();
59
+ }
60
+ if (filePath.startsWith('~/')) {
61
+ return path.join(os.homedir(), filePath.slice(2));
62
+ }
63
+ return filePath;
64
+ }
65
+ async function loadConfigInput(configPath) {
66
+ if (configPath.endsWith('.js') || configPath.endsWith('.mjs')) {
67
+ const module = await import(`${pathToFileURL(configPath).href}?t=${Date.now()}`);
68
+ return module.default;
69
+ }
70
+ const content = await readFile(configPath, 'utf8');
71
+ const transformed = content.replace(/^\s*export\s+default\s+/, 'return ');
72
+ if (transformed === content) {
73
+ throw new Error('配置文件必须使用 export default 导出配置对象');
74
+ }
75
+ const factory = new Function('process', transformed);
76
+ return factory(process);
77
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,114 @@
1
+ import { access } from 'node:fs/promises';
2
+ import { loadConfigFile } from './config.js';
3
+ import { shellQuote } from './remote.js';
4
+ export async function runDoctorFromFile(configPath, createClient) {
5
+ let config;
6
+ try {
7
+ config = await loadConfigFile(configPath);
8
+ }
9
+ catch (error) {
10
+ const message = error instanceof Error ? error.message : String(error);
11
+ const isMissing = message.includes('ENOENT');
12
+ return {
13
+ ok: false,
14
+ checks: [
15
+ {
16
+ name: isMissing ? '配置文件' : '配置字段',
17
+ status: 'fail',
18
+ message: isMissing ? `配置文件不存在: ${configPath}` : message,
19
+ },
20
+ ],
21
+ };
22
+ }
23
+ return runDoctor(config, createClient(config), { configPath });
24
+ }
25
+ export async function runDoctor(config, client, options = {}) {
26
+ const checks = [];
27
+ await addConfigFileCheck(checks, options.configPath);
28
+ checks.push({
29
+ name: '配置字段',
30
+ status: 'pass',
31
+ message: '配置字段有效',
32
+ });
33
+ await addSourcePathCheck(checks, config.source.path);
34
+ await addRemoteCheck(checks, 'SSH 连接', () => client.exec('true'), 'SSH 可连接');
35
+ await addRemoteCheck(checks, '远程目录', () => client.exec(`mkdir -p ${shellQuote(config.target.path)} && test -w ${shellQuote(config.target.path)}`), '远程目录可创建且可写');
36
+ try {
37
+ await client.exec('command -v tar');
38
+ checks.push({
39
+ name: '远端 tar',
40
+ status: 'pass',
41
+ message: '远端 tar 可用',
42
+ });
43
+ }
44
+ catch {
45
+ checks.push({
46
+ name: '远端 tar',
47
+ status: 'warn',
48
+ message: '远端 tar 不可用,将回退逐文件上传',
49
+ });
50
+ }
51
+ return {
52
+ ok: checks.every((check) => check.status !== 'fail'),
53
+ checks,
54
+ };
55
+ }
56
+ async function addConfigFileCheck(checks, configPath) {
57
+ if (!configPath) {
58
+ checks.push({
59
+ name: '配置文件',
60
+ status: 'pass',
61
+ message: '配置文件已加载',
62
+ });
63
+ return;
64
+ }
65
+ try {
66
+ await access(configPath);
67
+ checks.push({
68
+ name: '配置文件',
69
+ status: 'pass',
70
+ message: `配置文件存在: ${configPath}`,
71
+ });
72
+ }
73
+ catch {
74
+ checks.push({
75
+ name: '配置文件',
76
+ status: 'fail',
77
+ message: `配置文件不存在: ${configPath}`,
78
+ });
79
+ }
80
+ }
81
+ async function addSourcePathCheck(checks, sourcePath) {
82
+ try {
83
+ await access(sourcePath);
84
+ checks.push({
85
+ name: '本地源路径',
86
+ status: 'pass',
87
+ message: `本地源路径存在: ${sourcePath}`,
88
+ });
89
+ }
90
+ catch {
91
+ checks.push({
92
+ name: '本地源路径',
93
+ status: 'fail',
94
+ message: `本地源路径不存在: ${sourcePath}`,
95
+ });
96
+ }
97
+ }
98
+ async function addRemoteCheck(checks, name, run, successMessage) {
99
+ try {
100
+ await run();
101
+ checks.push({
102
+ name,
103
+ status: 'pass',
104
+ message: successMessage,
105
+ });
106
+ }
107
+ catch (error) {
108
+ checks.push({
109
+ name,
110
+ status: 'fail',
111
+ message: error instanceof Error ? error.message : String(error),
112
+ });
113
+ }
114
+ }
package/dist/list.js ADDED
@@ -0,0 +1,25 @@
1
+ import { readCurrentVersion, readRemoteReleaseEntries, remoteJoin, } from './remote.js';
2
+ export async function listReleases(config, client) {
3
+ if (config.deploy.mode === 'overwrite') {
4
+ return {
5
+ mode: 'overwrite',
6
+ targetPath: config.target.path,
7
+ releases: [],
8
+ };
9
+ }
10
+ const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
11
+ const currentSymlinkPath = remoteJoin(config.target.path, config.target.currentSymlink);
12
+ const [currentVersion, releases] = await Promise.all([
13
+ readCurrentVersion(client, currentSymlinkPath),
14
+ readRemoteReleaseEntries(client, releasesPath),
15
+ ]);
16
+ return {
17
+ mode: 'release',
18
+ targetPath: config.target.path,
19
+ currentVersion,
20
+ releases: releases.map((release) => ({
21
+ ...release,
22
+ current: release.version === currentVersion,
23
+ })),
24
+ };
25
+ }
@@ -0,0 +1,53 @@
1
+ import { mkdtemp, rm, stat } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { runProcess } from './process.js';
5
+ export async function createReleasePackage(options) {
6
+ const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'ssh-release-'));
7
+ const archivePath = path.join(tempDirectory, `${options.versionName}.tgz`);
8
+ const sourceStat = await stat(options.sourcePath);
9
+ const excludeArgs = options.exclude.flatMap((pattern) => ['--exclude', pattern]);
10
+ if (sourceStat.isDirectory()) {
11
+ await runProcess('tar', [
12
+ '-czf',
13
+ archivePath,
14
+ '--no-xattrs',
15
+ ...excludeArgs,
16
+ '-C',
17
+ options.sourcePath,
18
+ '.',
19
+ ], {
20
+ env: {
21
+ COPYFILE_DISABLE: '1',
22
+ },
23
+ });
24
+ }
25
+ else {
26
+ await runProcess('tar', [
27
+ '-czf',
28
+ archivePath,
29
+ '--no-xattrs',
30
+ ...excludeArgs,
31
+ '-C',
32
+ path.dirname(options.sourcePath),
33
+ path.basename(options.sourcePath),
34
+ ], {
35
+ env: {
36
+ COPYFILE_DISABLE: '1',
37
+ },
38
+ });
39
+ }
40
+ return {
41
+ archivePath,
42
+ cleanup: async () => {
43
+ await rm(tempDirectory, { recursive: true, force: true });
44
+ },
45
+ };
46
+ }
47
+ export async function listPackageEntries(archivePath) {
48
+ const result = await runProcess('tar', ['-tzf', archivePath]);
49
+ return result.stdout
50
+ .split('\n')
51
+ .map((line) => line.trim())
52
+ .filter(Boolean);
53
+ }
@@ -0,0 +1,29 @@
1
+ import { spawn } from 'node:child_process';
2
+ export async function runProcess(command, args, options = {}) {
3
+ return new Promise((resolve, reject) => {
4
+ const child = spawn(command, args, {
5
+ cwd: options.cwd,
6
+ env: {
7
+ ...process.env,
8
+ ...options.env,
9
+ },
10
+ stdio: ['ignore', 'pipe', 'pipe'],
11
+ });
12
+ const stdout = [];
13
+ const stderr = [];
14
+ child.stdout.on('data', (chunk) => stdout.push(chunk));
15
+ child.stderr.on('data', (chunk) => stderr.push(chunk));
16
+ child.on('error', reject);
17
+ child.on('close', (code) => {
18
+ const result = {
19
+ stdout: Buffer.concat(stdout).toString('utf8'),
20
+ stderr: Buffer.concat(stderr).toString('utf8'),
21
+ };
22
+ if (code === 0 || options.allowFailure) {
23
+ resolve(result);
24
+ return;
25
+ }
26
+ reject(new Error(`${command} 执行失败: ${result.stderr.trim() || `exit ${code}`}`));
27
+ });
28
+ });
29
+ }
@@ -0,0 +1,138 @@
1
+ import { access } from 'node:fs/promises';
2
+ import { createReleasePackage, } from './package.js';
3
+ import { readRemoteReleaseNames, remoteJoin, shellQuote, } from './remote.js';
4
+ export function createVersionName(date = new Date()) {
5
+ return [
6
+ date.getFullYear().toString(),
7
+ pad(date.getMonth() + 1),
8
+ pad(date.getDate()),
9
+ '-',
10
+ pad(date.getHours()),
11
+ pad(date.getMinutes()),
12
+ pad(date.getSeconds()),
13
+ ].join('');
14
+ }
15
+ export function selectReleasesToDelete(releases, currentVersion, keepReleases) {
16
+ if (!Number.isInteger(keepReleases) || keepReleases < 1) {
17
+ throw new Error('keepReleases 必须是正整数');
18
+ }
19
+ const sortedReleases = [...new Set(releases)].sort();
20
+ const retained = new Set(sortedReleases.slice(-keepReleases));
21
+ if (currentVersion) {
22
+ retained.add(currentVersion);
23
+ }
24
+ return sortedReleases.filter((release) => !retained.has(release));
25
+ }
26
+ function pad(value) {
27
+ return value.toString().padStart(2, '0');
28
+ }
29
+ export async function deploy(config, client, options = {}) {
30
+ await ensureSourceExists(config.source.path);
31
+ const versionName = createVersionName(options.now ?? new Date());
32
+ const packageFactory = options.createPackage ?? createReleasePackage;
33
+ const releasePackage = await packageFactory({
34
+ sourcePath: config.source.path,
35
+ exclude: config.source.exclude,
36
+ versionName,
37
+ });
38
+ try {
39
+ if (config.deploy.mode === 'overwrite') {
40
+ return await deployOverwrite(config, client, releasePackage, versionName);
41
+ }
42
+ return await deployRelease(config, client, releasePackage, versionName);
43
+ }
44
+ finally {
45
+ await releasePackage.cleanup();
46
+ }
47
+ }
48
+ async function deployRelease(config, client, releasePackage, versionName) {
49
+ const warnings = [];
50
+ const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
51
+ const releasePath = remoteJoin(releasesPath, versionName);
52
+ const tempPath = remoteJoin(config.target.path, config.target.tempDir);
53
+ const remoteArchivePath = remoteJoin(tempPath, `${versionName}.tgz`);
54
+ const currentSymlinkPath = remoteJoin(config.target.path, config.target.currentSymlink);
55
+ await client.exec(`mkdir -p ${shellQuote(releasesPath)} ${shellQuote(tempPath)} ${shellQuote(releasePath)}`);
56
+ await client.uploadFile(releasePackage.archivePath, remoteArchivePath);
57
+ const usedFallback = await publishPackageToRemotePath(config, client, remoteArchivePath, releasePath, true);
58
+ await client.exec(`ln -sfn ${shellQuote(remoteJoin(config.target.releasesDir, versionName))} ${shellQuote(currentSymlinkPath)}`);
59
+ await cleanupRemoteArchive(client, remoteArchivePath, warnings);
60
+ await cleanupOldReleases(config, client, releasesPath, versionName, warnings);
61
+ return {
62
+ mode: 'release',
63
+ version: versionName,
64
+ targetPath: releasePath,
65
+ currentSymlink: currentSymlinkPath,
66
+ usedFallback,
67
+ warnings,
68
+ };
69
+ }
70
+ async function deployOverwrite(config, client, releasePackage, versionName) {
71
+ const warnings = [];
72
+ const tempPath = remoteJoin(config.target.path, config.target.tempDir);
73
+ const remoteArchivePath = remoteJoin(tempPath, `${versionName}.tgz`);
74
+ await client.exec(`mkdir -p ${shellQuote(config.target.path)} ${shellQuote(tempPath)}`);
75
+ await client.uploadFile(releasePackage.archivePath, remoteArchivePath);
76
+ const usedFallback = await publishPackageToRemotePath(config, client, remoteArchivePath, config.target.path, false);
77
+ await cleanupRemoteArchive(client, remoteArchivePath, warnings);
78
+ return {
79
+ mode: 'overwrite',
80
+ targetPath: config.target.path,
81
+ usedFallback,
82
+ warnings,
83
+ };
84
+ }
85
+ async function publishPackageToRemotePath(config, client, remoteArchivePath, remoteTargetPath, cleanBeforeFallback) {
86
+ if (!config.deploy.preferTar) {
87
+ await client.uploadDirectory(config.source.path, remoteTargetPath, config.source.exclude);
88
+ return true;
89
+ }
90
+ try {
91
+ await client.exec(`tar -xzf ${shellQuote(remoteArchivePath)} -C ${shellQuote(remoteTargetPath)}`);
92
+ return false;
93
+ }
94
+ catch (error) {
95
+ if (!config.deploy.fallbackToFileUpload) {
96
+ throw error;
97
+ }
98
+ if (cleanBeforeFallback) {
99
+ await client.exec(`rm -rf ${shellQuote(remoteTargetPath)} && mkdir -p ${shellQuote(remoteTargetPath)}`);
100
+ }
101
+ else {
102
+ await client.exec(`mkdir -p ${shellQuote(remoteTargetPath)}`);
103
+ }
104
+ await client.uploadDirectory(config.source.path, remoteTargetPath, config.source.exclude);
105
+ return true;
106
+ }
107
+ }
108
+ async function cleanupRemoteArchive(client, remoteArchivePath, warnings) {
109
+ try {
110
+ await client.exec(`rm -f ${shellQuote(remoteArchivePath)}`);
111
+ }
112
+ catch (error) {
113
+ warnings.push(`远程临时包清理失败: ${formatError(error)}`);
114
+ }
115
+ }
116
+ async function cleanupOldReleases(config, client, releasesPath, currentVersion, warnings) {
117
+ try {
118
+ const releases = await readRemoteReleaseNames(client, releasesPath);
119
+ const releasesToDelete = selectReleasesToDelete([...releases, currentVersion], currentVersion, config.deploy.keepReleases);
120
+ for (const release of releasesToDelete) {
121
+ await client.exec(`rm -rf ${shellQuote(remoteJoin(releasesPath, release))}`);
122
+ }
123
+ }
124
+ catch (error) {
125
+ warnings.push(`旧版本清理失败: ${formatError(error)}`);
126
+ }
127
+ }
128
+ async function ensureSourceExists(sourcePath) {
129
+ try {
130
+ await access(sourcePath);
131
+ }
132
+ catch {
133
+ throw new Error(`source.path 不存在: ${sourcePath}`);
134
+ }
135
+ }
136
+ function formatError(error) {
137
+ return error instanceof Error ? error.message : String(error);
138
+ }
package/dist/remote.js ADDED
@@ -0,0 +1,38 @@
1
+ import path from 'node:path';
2
+ export function shellQuote(value) {
3
+ return `'${value.replaceAll("'", "'\\''")}'`;
4
+ }
5
+ export function remoteJoin(...parts) {
6
+ return path.posix.join(...parts);
7
+ }
8
+ export async function readRemoteReleaseNames(client, releasesPath) {
9
+ const result = await client.exec(`if [ -d ${shellQuote(releasesPath)} ]; then for release_path in ${shellQuote(releasesPath)}/*; do [ -d "$release_path" ] || continue; basename "$release_path"; done; fi`);
10
+ return result.stdout
11
+ .split('\n')
12
+ .map((line) => line.trim())
13
+ .filter(Boolean)
14
+ .sort();
15
+ }
16
+ export async function readRemoteReleaseEntries(client, releasesPath) {
17
+ const result = await client.exec(`if [ -d ${shellQuote(releasesPath)} ]; then for release_path in ${shellQuote(releasesPath)}/*; do [ -d "$release_path" ] || continue; version=$(basename "$release_path"); modified=$(stat -c %Y "$release_path" 2>/dev/null || stat -f %m "$release_path" 2>/dev/null || echo 0); printf '%s\\t%s\\n' "$version" "$modified"; done; fi`);
18
+ return result.stdout
19
+ .split('\n')
20
+ .map((line) => line.trim())
21
+ .filter(Boolean)
22
+ .map((line) => {
23
+ const [version, modifiedAtSeconds = '0'] = line.split('\t');
24
+ return {
25
+ version,
26
+ modifiedAt: new Date(Number(modifiedAtSeconds) * 1000),
27
+ };
28
+ })
29
+ .sort((left, right) => left.version.localeCompare(right.version));
30
+ }
31
+ export async function readCurrentVersion(client, currentSymlinkPath) {
32
+ const result = await client.exec(`readlink ${shellQuote(currentSymlinkPath)} 2>/dev/null || true`);
33
+ const linkTarget = result.stdout.trim();
34
+ if (!linkTarget) {
35
+ return undefined;
36
+ }
37
+ return path.posix.basename(linkTarget);
38
+ }
@@ -0,0 +1,40 @@
1
+ import { readCurrentVersion, readRemoteReleaseNames, remoteJoin, shellQuote, } from './remote.js';
2
+ export function selectRollbackTarget(selection) {
3
+ const releases = [...new Set(selection.releases)].sort();
4
+ if (selection.requestedVersion) {
5
+ if (!releases.includes(selection.requestedVersion)) {
6
+ throw new Error(`回滚目标不存在: ${selection.requestedVersion}`);
7
+ }
8
+ return selection.requestedVersion;
9
+ }
10
+ const currentIndex = releases.indexOf(selection.currentVersion);
11
+ if (currentIndex === -1) {
12
+ throw new Error(`当前版本不存在: ${selection.currentVersion}`);
13
+ }
14
+ if (currentIndex === 0) {
15
+ throw new Error('没有可回滚版本');
16
+ }
17
+ return releases[currentIndex - 1];
18
+ }
19
+ export async function rollback(config, client, requestedVersion) {
20
+ if (config.deploy.mode === 'overwrite') {
21
+ throw new Error('overwrite 模式不支持回滚');
22
+ }
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('当前版本不存在');
29
+ }
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
+ };
40
+ }
package/dist/ssh.js ADDED
@@ -0,0 +1,139 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { runProcess } from './process.js';
4
+ import { remoteJoin, shellQuote } from './remote.js';
5
+ export class ShellRemoteClient {
6
+ server;
7
+ constructor(server) {
8
+ this.server = server;
9
+ }
10
+ async exec(command) {
11
+ const spec = createSshProcessSpec(this.server, command);
12
+ return runProcess(spec.command, spec.args, { env: spec.env });
13
+ }
14
+ async uploadFile(localPath, remotePath) {
15
+ const spec = createScpProcessSpec(this.server, localPath, remotePath);
16
+ await runProcess(spec.command, spec.args, { env: spec.env });
17
+ }
18
+ async uploadDirectory(localPath, remotePath, exclude) {
19
+ await uploadDirectoryContents(this, localPath, remotePath, exclude);
20
+ }
21
+ }
22
+ export function createRemoteClient(config) {
23
+ return new ShellRemoteClient(config.server);
24
+ }
25
+ export function createScpRemoteTarget(destination, remotePath) {
26
+ return `${destination}:${remotePath}`;
27
+ }
28
+ export function createSshProcessSpec(server, remoteCommand) {
29
+ const destination = `${server.username}@${server.host}`;
30
+ if (server.password) {
31
+ return {
32
+ command: 'sshpass',
33
+ args: [
34
+ '-e',
35
+ 'ssh',
36
+ '-p',
37
+ String(server.port),
38
+ '-o',
39
+ 'StrictHostKeyChecking=accept-new',
40
+ '-o',
41
+ 'PreferredAuthentications=password',
42
+ '-o',
43
+ 'PubkeyAuthentication=no',
44
+ '-o',
45
+ 'NumberOfPasswordPrompts=1',
46
+ destination,
47
+ remoteCommand,
48
+ ],
49
+ env: {
50
+ SSHPASS: server.password,
51
+ },
52
+ };
53
+ }
54
+ return {
55
+ command: 'ssh',
56
+ args: [
57
+ '-p',
58
+ String(server.port),
59
+ '-i',
60
+ requirePrivateKeyPath(server),
61
+ '-o',
62
+ 'BatchMode=yes',
63
+ destination,
64
+ remoteCommand,
65
+ ],
66
+ };
67
+ }
68
+ export function createScpProcessSpec(server, localPath, remotePath) {
69
+ const destination = `${server.username}@${server.host}`;
70
+ if (server.password) {
71
+ return {
72
+ command: 'sshpass',
73
+ args: [
74
+ '-e',
75
+ 'scp',
76
+ '-P',
77
+ String(server.port),
78
+ '-o',
79
+ 'StrictHostKeyChecking=accept-new',
80
+ '-o',
81
+ 'PreferredAuthentications=password',
82
+ '-o',
83
+ 'PubkeyAuthentication=no',
84
+ '-o',
85
+ 'NumberOfPasswordPrompts=1',
86
+ localPath,
87
+ createScpRemoteTarget(destination, remotePath),
88
+ ],
89
+ env: {
90
+ SSHPASS: server.password,
91
+ },
92
+ };
93
+ }
94
+ return {
95
+ command: 'scp',
96
+ args: [
97
+ '-P',
98
+ String(server.port),
99
+ '-i',
100
+ requirePrivateKeyPath(server),
101
+ '-o',
102
+ 'BatchMode=yes',
103
+ localPath,
104
+ createScpRemoteTarget(destination, remotePath),
105
+ ],
106
+ };
107
+ }
108
+ function requirePrivateKeyPath(server) {
109
+ if (!server.privateKeyPath) {
110
+ throw new Error('server.privateKeyPath 或 server.password 必须配置一个');
111
+ }
112
+ return server.privateKeyPath;
113
+ }
114
+ async function uploadDirectoryContents(client, localPath, remotePath, exclude) {
115
+ const sourceStat = await stat(localPath);
116
+ if (!sourceStat.isDirectory()) {
117
+ await client.uploadFile(localPath, remoteJoin(remotePath, path.basename(localPath)));
118
+ return;
119
+ }
120
+ await walkDirectory(client, localPath, remotePath, exclude);
121
+ }
122
+ async function walkDirectory(client, localDirectory, remoteDirectory, exclude) {
123
+ await client.exec(`mkdir -p ${shellQuote(remoteDirectory)}`);
124
+ const entries = await readdir(localDirectory, { withFileTypes: true });
125
+ for (const entry of entries) {
126
+ if (exclude.includes(entry.name)) {
127
+ continue;
128
+ }
129
+ const localEntryPath = path.join(localDirectory, entry.name);
130
+ const remoteEntryPath = remoteJoin(remoteDirectory, entry.name);
131
+ if (entry.isDirectory()) {
132
+ await walkDirectory(client, localEntryPath, remoteEntryPath, exclude);
133
+ continue;
134
+ }
135
+ if (entry.isFile()) {
136
+ await client.uploadFile(localEntryPath, remoteEntryPath);
137
+ }
138
+ }
139
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,102 @@
1
+ import path from 'node:path';
2
+ const dangerousTargetPaths = new Set([
3
+ '/',
4
+ '/home',
5
+ '/root',
6
+ '/var',
7
+ '/usr',
8
+ '/etc',
9
+ '/opt',
10
+ '/tmp',
11
+ ]);
12
+ export function validateTargetPath(targetPath) {
13
+ if (!targetPath || !targetPath.trim()) {
14
+ throw new Error('target.path 不能为空');
15
+ }
16
+ const normalizedPath = path.posix.normalize(targetPath.trim());
17
+ if (!normalizedPath.startsWith('/')) {
18
+ throw new Error('target.path 必须是远程绝对路径');
19
+ }
20
+ if (dangerousTargetPaths.has(normalizedPath)) {
21
+ throw new Error(`危险远程路径: ${normalizedPath}`);
22
+ }
23
+ return normalizedPath;
24
+ }
25
+ export function normalizeConfig(input) {
26
+ const mode = input.deploy?.mode ?? 'release';
27
+ const compression = input.deploy?.compression ?? 'tgz';
28
+ const keepReleases = input.deploy?.keepReleases ?? 5;
29
+ const port = input.server?.port ?? 22;
30
+ const privateKeyPath = optionalString(input.server?.privateKeyPath);
31
+ const password = optionalString(input.server?.password);
32
+ if (mode !== 'release' && mode !== 'overwrite') {
33
+ throw new Error('deploy.mode 只支持 release 或 overwrite');
34
+ }
35
+ if (compression !== 'tgz') {
36
+ throw new Error('deploy.compression 第一版只支持 tgz');
37
+ }
38
+ if (!Number.isInteger(keepReleases) || keepReleases < 1) {
39
+ throw new Error('deploy.keepReleases 必须是正整数');
40
+ }
41
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
42
+ throw new Error('server.port 必须是 1 到 65535 之间的整数');
43
+ }
44
+ const exclude = input.source?.exclude ?? [];
45
+ if (!Array.isArray(exclude)) {
46
+ throw new Error('source.exclude 必须是字符串数组');
47
+ }
48
+ if (exclude.some((entry) => typeof entry !== 'string')) {
49
+ throw new Error('source.exclude 必须是字符串数组');
50
+ }
51
+ if (!privateKeyPath && !password) {
52
+ throw new Error('server.privateKeyPath 或 server.password 必须配置一个');
53
+ }
54
+ return {
55
+ source: {
56
+ path: requiredString(input.source?.path, 'source.path'),
57
+ exclude,
58
+ },
59
+ server: {
60
+ host: requiredString(input.server?.host, 'server.host'),
61
+ port,
62
+ username: requiredString(input.server?.username, 'server.username'),
63
+ privateKeyPath,
64
+ password,
65
+ },
66
+ target: {
67
+ path: validateTargetPath(requiredString(input.target?.path, 'target.path')),
68
+ currentSymlink: validateTargetName(input.target?.currentSymlink ?? 'current', 'target.currentSymlink'),
69
+ releasesDir: validateTargetName(input.target?.releasesDir ?? 'releases', 'target.releasesDir'),
70
+ tempDir: validateTargetName(input.target?.tempDir ?? '.ssh-release-tmp', 'target.tempDir'),
71
+ },
72
+ deploy: {
73
+ mode: mode,
74
+ keepReleases,
75
+ compression: compression,
76
+ preferTar: input.deploy?.preferTar ?? true,
77
+ fallbackToFileUpload: input.deploy?.fallbackToFileUpload ?? true,
78
+ },
79
+ };
80
+ }
81
+ function requiredString(value, fieldName) {
82
+ if (!value || !value.trim()) {
83
+ throw new Error(`${fieldName} 不能为空`);
84
+ }
85
+ return value.trim();
86
+ }
87
+ function optionalString(value) {
88
+ if (!value || !value.trim()) {
89
+ return undefined;
90
+ }
91
+ return value.trim();
92
+ }
93
+ function validateTargetName(value, fieldName) {
94
+ const normalizedValue = requiredString(value, fieldName);
95
+ if (normalizedValue === '.' ||
96
+ normalizedValue === '..' ||
97
+ normalizedValue.includes('/') ||
98
+ normalizedValue.includes('\\')) {
99
+ throw new Error(`${fieldName} 必须是简单相对名称`);
100
+ }
101
+ return normalizedValue;
102
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "ssh-release",
3
+ "version": "0.1.0",
4
+ "description": "A general-purpose SSH file release CLI.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ssh-release": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "CHANGELOG.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20.0.0"
17
+ },
18
+ "scripts": {
19
+ "dev": "tsx src/cli.ts",
20
+ "build": "tsc -p tsconfig.build.json",
21
+ "lint": "tsc --noEmit -p tsconfig.json",
22
+ "test": "node --import tsx --test tests/*.test.ts",
23
+ "prepack": "npm run build",
24
+ "prepublishOnly": "npm run lint && npm test && npm run build"
25
+ },
26
+ "keywords": [
27
+ "ssh",
28
+ "release",
29
+ "deploy",
30
+ "cli"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/JackEngineer/ssh-release.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/JackEngineer/ssh-release/issues"
38
+ },
39
+ "homepage": "https://github.com/JackEngineer/ssh-release#readme",
40
+ "license": "MIT",
41
+ "devDependencies": {
42
+ "@types/node": "^20.19.1",
43
+ "tsx": "^4.20.3",
44
+ "typescript": "^5.8.3"
45
+ }
46
+ }