dsh-app-deploy 0.1.0 → 0.2.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/README.md +12 -3
- package/client.js +6 -4
- package/package.json +2 -1
- package/server/config.js +7 -5
- package/server/deployment.js +88 -29
- package/server/local-tools.js +34 -9
- package/server/rpc.js +7 -2
package/README.md
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# dsh-app-deploy
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-app-deploy)
|
|
4
|
+
|
|
3
5
|
[DeepSeek Harness](https://www.npmjs.com/package/@deepseek-ai/dsh)(DSH)插件:把交互式部署工具收进 DSH Web 面板。按环境分组管理配置,勾选模块、拖拽编排执行顺序,一键完成 Gradle 构建、rsync 同步与 SSH 远程命令,实时推送日志并保留执行记录。
|
|
4
6
|
|
|
7
|
+

|
|
8
|
+
|
|
5
9
|
## 特性
|
|
6
10
|
|
|
7
11
|
- **部署面板**:侧边栏「部署」入口,按环境分组管理,模块勾选 + 拖拽排序,一键部署。
|
|
@@ -16,19 +20,23 @@
|
|
|
16
20
|
| --- | --- |
|
|
17
21
|
| Node.js | >= 22 |
|
|
18
22
|
| DSH | >= 0.2.1-rc.3, < 0.3.0 |
|
|
19
|
-
| 部署机工具 | `rsync
|
|
23
|
+
| 部署机工具 | `rsync`(或 `rclone`)、`ssh`;rsync + SSH 密码时另需 `sshpass`(无 Windows 版) |
|
|
24
|
+
|
|
25
|
+
> **同步工具回退**:本机有 `rsync` 优先用 rsync over ssh;没有 rsync 时自动回退 `rclone`(SFTP 后端):前端模块用 `rclone sync`(自带镜像删除,等价 `--delete`),服务端模块用 `rclone copy`(不删除)。rclone 同步自身支持密码与密钥(密钥取 SSH 自定义选项里的 `-i`),不依赖 sshpass。
|
|
26
|
+
>
|
|
27
|
+
> **Windows 主机**:Gradle 构建(`gradlew.bat`)、JDK 自动发现(`JAVA_HOME` + 常见安装目录)、目录浏览均可原生工作。装一个 [rclone](https://rclone.org) 即可获得完整同步能力(含密码认证);仅当模块配置了远程命令时才需要系统自带 OpenSSH 的 `ssh`——远程命令 + 密码依赖 sshpass(Windows 无此工具),请改用密钥认证(SSH 自定义选项填 `-i 私钥路径`,并清空密码)。
|
|
20
28
|
|
|
21
29
|
## 安装
|
|
22
30
|
|
|
23
31
|
插件发布在 npm,通过 DSH 插件管理器直接安装:
|
|
24
32
|
|
|
25
33
|
```text
|
|
26
|
-
plugin_manager → action: install_bundle → target: dsh-app-deploy
|
|
34
|
+
plugin_manager → action: install_bundle → target: dsh-app-deploy@0.2.0
|
|
27
35
|
```
|
|
28
36
|
|
|
29
37
|
安装后刷新 DSH Web,左侧导航出现「部署」入口。
|
|
30
38
|
|
|
31
|
-
卸载用 `plugin_manager` 的 `remove_bundle
|
|
39
|
+
卸载用 `plugin_manager` 的 `remove_bundle`;升级先卸载再安装新版本(`target` 换成新的 `dsh-app-deploy@<version>`)。
|
|
32
40
|
|
|
33
41
|
## 配置
|
|
34
42
|
|
|
@@ -48,6 +56,7 @@ plugin_manager → action: install_bundle → target: dsh-app-deploy
|
|
|
48
56
|
```
|
|
49
57
|
清理构建目录 → ./gradlew <task> [-Pkey] [额外参数] [-PexpTime]
|
|
50
58
|
→ rsync [-r -t -z] 本地产物 → user@host:远程路径(前端加 --delete)
|
|
59
|
+
(无 rsync 时回退 rclone:前端 rclone sync / 服务端 rclone copy,走 SFTP)
|
|
51
60
|
→ ssh 远程命令(可空)
|
|
52
61
|
→ 健康检查(可空:HTTP 2xx + success=true + code=200)
|
|
53
62
|
```
|
package/client.js
CHANGED
|
@@ -48,17 +48,19 @@ window.__ModuleLoader__.load({
|
|
|
48
48
|
}
|
|
49
49
|
function clone(v) { return JSON.parse(JSON.stringify(v)) }
|
|
50
50
|
function normPath(p) { return (p || '').replace(/\/+$/, '') }
|
|
51
|
+
// 绝对路径:posix 以 / 开头,Windows 为盘符或 \ 开头(如 C:\proj、D:/proj)
|
|
52
|
+
function isAbsPath(p) { return /^([a-zA-Z]:[\\/]|[\\/])/.test(p || '') }
|
|
51
53
|
function joinPath(base, name) {
|
|
52
54
|
if (!name) return base || ''
|
|
53
|
-
if (name
|
|
55
|
+
if (isAbsPath(name)) return name
|
|
54
56
|
const b = base || ''
|
|
55
57
|
if (!b) return name
|
|
56
|
-
return b === '/' ? '/' + name : b.replace(
|
|
58
|
+
return b === '/' ? '/' + name : b.replace(/[\\/]+$/, '') + '/' + name
|
|
57
59
|
}
|
|
58
60
|
function parentPath(p) {
|
|
59
61
|
const base = normPath(p || '')
|
|
60
|
-
if (!base || base === '/') return '/'
|
|
61
|
-
const i = base.lastIndexOf('/')
|
|
62
|
+
if (!base || base === '/' || /^[a-zA-Z]:[\\/]?$/.test(base)) return base || '/'
|
|
63
|
+
const i = Math.max(base.lastIndexOf('/'), base.lastIndexOf('\\'))
|
|
62
64
|
return i <= 0 ? '/' : base.slice(0, i)
|
|
63
65
|
}
|
|
64
66
|
function newEnvId(envs) {
|
package/package.json
CHANGED
package/server/config.js
CHANGED
|
@@ -138,6 +138,8 @@ export function getDefaultProjectPath() { return defaultProjectPath }
|
|
|
138
138
|
|
|
139
139
|
// ---------- 校验 ----------
|
|
140
140
|
const BAD_PATH = /[\s;"'`$&|<>()*?[\]{}!]/ // 禁止空格/引号/; & | ` $ 等
|
|
141
|
+
// 绝对路径:posix 以 / 开头,Windows 为盘符 + \ 或 /(如 C:\jdk、D:/proj)
|
|
142
|
+
const isAbsPath = (p) => /^(\/|[a-zA-Z]:[\\/])/.test(p)
|
|
141
143
|
const BAD_CMD = /["'`;&|<>$()*?[\]{}!]/ // 允许空格,其余同上
|
|
142
144
|
|
|
143
145
|
// 前端 .env 文本:只允许 KEY=VALUE(KEY 为 A-Z0-9_)、空行、# 注释
|
|
@@ -152,11 +154,11 @@ export function validateFrontEnv(s) {
|
|
|
152
154
|
|
|
153
155
|
// 前端 .env 文件路径(仅种子 bsFront/adminFront 有约定路径,供「配置为空时读文件作参考」)
|
|
154
156
|
export function frontEnvFile(e, m) {
|
|
155
|
-
const p = (e.projectPath || defaultProjectPath || '').replace(
|
|
157
|
+
const p = (e.projectPath || defaultProjectPath || '').replace(/[\\/]+$/, '')
|
|
156
158
|
if (!p) return null
|
|
157
159
|
const key = m.key ? '.' + m.key : ''
|
|
158
|
-
if (m.id === 'bsFront') return
|
|
159
|
-
if (m.id === 'adminFront') return
|
|
160
|
+
if (m.id === 'bsFront') return path.join(p, 'client/client-front', `.env${key}`)
|
|
161
|
+
if (m.id === 'adminFront') return path.join(p, 'admin/admin-front', `.env${key}`)
|
|
160
162
|
return null
|
|
161
163
|
}
|
|
162
164
|
|
|
@@ -165,9 +167,9 @@ export function validateEnv(e) {
|
|
|
165
167
|
if (!/^[a-z0-9-]+$/.test(e.id)) errs.push('ID 只能是小写字母/数字/连字符')
|
|
166
168
|
if (!e.name.trim()) errs.push('名称必填')
|
|
167
169
|
if (e.group && !/^[\u4e00-\u9fa5A-Za-z0-9 -]{1,20}$/.test(e.group)) errs.push('分组只能含中文/字母/数字/空格/连字符,最长 20 字符')
|
|
168
|
-
if (!e.projectPath
|
|
170
|
+
if (!isAbsPath(e.projectPath)) errs.push('项目路径必须是绝对路径(如 /opt/proj 或 C:\\proj)')
|
|
169
171
|
if (BAD_PATH.test(e.projectPath)) errs.push('项目路径含非法字符(禁止空格/引号/; & | ` $ 等)')
|
|
170
|
-
if (e.jdkHome && !e.jdkHome
|
|
172
|
+
if (e.jdkHome && !isAbsPath(e.jdkHome)) errs.push('JDK 路径必须是绝对路径')
|
|
171
173
|
if (e.jdkHome && BAD_PATH.test(e.jdkHome)) errs.push('JDK 路径含非法字符')
|
|
172
174
|
const s = e.ssh
|
|
173
175
|
if (!/^[a-zA-Z0-9.-]+$/.test(s.host)) errs.push('SSH host 非法(仅字母/数字/./-)')
|
package/server/deployment.js
CHANGED
|
@@ -3,10 +3,13 @@ import { promises as fsp } from 'node:fs'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { getEnvs } from './config.js'
|
|
5
5
|
import { getHistory, saveHistory, LOGTAIL_CHARS } from './history.js'
|
|
6
|
-
import { listWorktrees, which } from './local-tools.js'
|
|
6
|
+
import { listWorktrees, which, rcloneObscure } from './local-tools.js'
|
|
7
7
|
import { checkHealth } from './health-check.js'
|
|
8
8
|
|
|
9
9
|
// ---------- SSE 日志与部署锁(同一时刻只允许一个部署) ----------
|
|
10
|
+
const IS_WIN = process.platform === 'win32'
|
|
11
|
+
// Windows 上 gradlew 是 .bat,必须经 cmd 启动(shell:true 仅用于 win32;参数均过 BAD_CMD 白名单校验)
|
|
12
|
+
const GRADLEW = IS_WIN ? 'gradlew.bat' : './gradlew'
|
|
10
13
|
let running = null // { clients: Set<ServerResponse>, done, label, record, logTail, lastCode }
|
|
11
14
|
let activeProc = null // 当前正在执行的子进程(gradlew/rsync/ssh),插件卸载时终止
|
|
12
15
|
|
|
@@ -42,7 +45,11 @@ setInterval(() => {
|
|
|
42
45
|
// 插件卸载/重载时终止正在运行的构建子进程,避免 gradlew 成为孤儿继续跑;
|
|
43
46
|
// gradlew 客户端被杀后,daemon 端会因连接断开取消本次构建
|
|
44
47
|
export function killActiveProcess() {
|
|
45
|
-
|
|
48
|
+
// Windows 上构建进程跑在 cmd.exe 下,kill() 只杀 cmd 会留下 java 孤儿,必须 taskkill 整棵树
|
|
49
|
+
try {
|
|
50
|
+
if (activeProc?.pid && IS_WIN) spawn('taskkill', ['/pid', String(activeProc.pid), '/t', '/f'])
|
|
51
|
+
else activeProc?.kill()
|
|
52
|
+
} catch {}
|
|
46
53
|
activeProc = null
|
|
47
54
|
}
|
|
48
55
|
|
|
@@ -59,7 +66,7 @@ function runStep(label, args, opts = {}) {
|
|
|
59
66
|
}
|
|
60
67
|
let child
|
|
61
68
|
try {
|
|
62
|
-
child = spawn(args[0], args.slice(1), { cwd: opts.cwd, env: opts.env, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
69
|
+
child = spawn(args[0], args.slice(1), { cwd: opts.cwd, env: opts.env, shell: opts.shell, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
63
70
|
activeProc = child
|
|
64
71
|
} catch (err) {
|
|
65
72
|
broadcast(`启动失败: ${String(err)}\n`)
|
|
@@ -79,7 +86,7 @@ async function stopGradleDaemon(env) {
|
|
|
79
86
|
try {
|
|
80
87
|
const stopEnv = env.jdkHome ? { ...process.env, JAVA_HOME: env.jdkHome } : process.env
|
|
81
88
|
await new Promise((resolve) => {
|
|
82
|
-
const p = spawn(
|
|
89
|
+
const p = spawn(GRADLEW, ['--stop'], { cwd: env.projectPath, env: stopEnv, shell: IS_WIN, stdio: 'ignore' })
|
|
83
90
|
p.once('error', resolve)
|
|
84
91
|
p.once('close', resolve)
|
|
85
92
|
})
|
|
@@ -125,25 +132,27 @@ async function rmTree(p) {
|
|
|
125
132
|
// 前端(kind=front):取 localPath 中 dist 及之前部分(client/client-front/dist/fxy/ → …/dist)
|
|
126
133
|
// 服务端(kind=server):清空 localPath 所在目录(…/build/libs/financial-v5.jar → …/build/libs;目录型 localPath 清空自身)
|
|
127
134
|
export function cleanTarget(env, m) {
|
|
128
|
-
const pp = env.projectPath.replace(
|
|
129
|
-
const local = m.localPath
|
|
135
|
+
const pp = path.resolve(env.projectPath.replace(/[\\/]+$/, ''))
|
|
136
|
+
const local = path.isAbsolute(m.localPath) ? m.localPath : path.join(pp, m.localPath)
|
|
137
|
+
// 统一成正斜杠视图做推导,兼容 Windows 反斜杠路径
|
|
138
|
+
const lv = local.replace(/\\/g, '/')
|
|
130
139
|
let rel
|
|
131
140
|
if (m.kind === 'front') {
|
|
132
|
-
const i =
|
|
141
|
+
const i = lv.lastIndexOf('/dist')
|
|
133
142
|
if (i < 0) return null
|
|
134
|
-
rel =
|
|
143
|
+
rel = lv.slice(0, i + 5)
|
|
135
144
|
} else {
|
|
136
|
-
|
|
137
|
-
const
|
|
145
|
+
// 目录型(以 / 或 \ 结尾)清空自身,文件型清空所在目录
|
|
146
|
+
const isDir = /[\\/]$/.test(local)
|
|
147
|
+
const p = lv.replace(/\/+$/, '')
|
|
138
148
|
rel = isDir ? p : p.slice(0, p.lastIndexOf('/'))
|
|
139
149
|
}
|
|
140
|
-
const clean = rel.replace(/\/+/g, '/').replace(/\/+$/, '')
|
|
141
150
|
// 安全约束:不含 ..、解析后须在 projectPath 内、深度 ≥ projectPath 下 2 级
|
|
142
|
-
if (!
|
|
143
|
-
const resolved =
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
151
|
+
if (!rel || rel.includes('..')) return null
|
|
152
|
+
const resolved = path.resolve(rel.replace(/\/+/g, '/'))
|
|
153
|
+
const relToPp = path.relative(pp, resolved)
|
|
154
|
+
if (relToPp.startsWith('..') || path.isAbsolute(relToPp)) return null
|
|
155
|
+
if (relToPp.split(/[\\/]/).length < 2) return null
|
|
147
156
|
return resolved
|
|
148
157
|
}
|
|
149
158
|
|
|
@@ -167,14 +176,29 @@ async function cleanBuildDir(env, m) {
|
|
|
167
176
|
}
|
|
168
177
|
}
|
|
169
178
|
|
|
179
|
+
// 组装 rclone 同步参数(SFTP 后端):sync 自带 --delete 用于前端镜像,copy 不删用于服务端;
|
|
180
|
+
// 密码用 obscure 混淆值(只进 spawn 参数,不进日志标签);密钥取 SSH 自定义选项里的 -i
|
|
181
|
+
export function buildRcloneArgs(env, m, local, rclone) {
|
|
182
|
+
const isFront = m.kind === 'front'
|
|
183
|
+
const args = [isFront ? 'sync' : 'copy', local, `:sftp:${m.remotePath}`,
|
|
184
|
+
'--sftp-host', env.ssh.host, '--sftp-port', String(env.ssh.port), '--sftp-user', env.ssh.user,
|
|
185
|
+
'--stats-one-line', '--stats', '10s', '-v']
|
|
186
|
+
if (isFront) args.push('--exclude', '.user.ini') // 宝塔锁定文件;exclude 同时豁免 sync 的删除
|
|
187
|
+
if (rclone.pass) args.push('--sftp-pass', rclone.pass)
|
|
188
|
+
const key = (env.ssh.options || '').match(/(?:^|\s)-i\s+(\S+)/)
|
|
189
|
+
if (!rclone.pass && key) args.push('--sftp-key-file', key[1])
|
|
190
|
+
return args
|
|
191
|
+
}
|
|
192
|
+
|
|
170
193
|
// 返回退出码与失败阶段(清理/构建/同步/远程命令),用于执行记录
|
|
171
|
-
|
|
194
|
+
// rclone 非空时用 rclone(SFTP)替代 rsync 同步;rsyncEnv 同时承载 sshpass 的 SSHPASS
|
|
195
|
+
export async function deployModule(env, m, sshBase, dest, sshpass, rclone = null) {
|
|
172
196
|
// 0. 清理构建目录(恢复原 update-*.sh 行为)
|
|
173
197
|
const c0 = await cleanBuildDir(env, m)
|
|
174
198
|
if (c0 !== 0) return { code: c0, step: '清理' }
|
|
175
199
|
|
|
176
200
|
// 1. 构建
|
|
177
|
-
const buildArgs = [
|
|
201
|
+
const buildArgs = [GRADLEW, m.gradleTask]
|
|
178
202
|
if (m.key) buildArgs.push(`-Pkey=${m.key}`)
|
|
179
203
|
if (m.extraArgs) buildArgs.push(...m.extraArgs.trim().split(/\s+/).filter(Boolean))
|
|
180
204
|
if (m.kind === 'server' && env.expTime) buildArgs.push(`-PexpTime=${env.expTime}`)
|
|
@@ -191,17 +215,26 @@ export async function deployModule(env, m, sshBase, dest, sshpass) {
|
|
|
191
215
|
if (/^[A-Za-z0-9_]+$/.test(k)) envForGradle[k] = v
|
|
192
216
|
}
|
|
193
217
|
}
|
|
194
|
-
const c1 = await runStep(`[${m.name}] 构建: ${buildArgs.join(' ')} ${jdkNote}`, buildArgs, { cwd: env.projectPath, env: envForGradle })
|
|
218
|
+
const c1 = await runStep(`[${m.name}] 构建: ${buildArgs.join(' ')} ${jdkNote}`, buildArgs, { cwd: env.projectPath, env: envForGradle, shell: IS_WIN })
|
|
195
219
|
if (c1 !== 0) { broadcast(`[${m.name}] 构建失败,退出码 ${c1},中止部署\n`); return { code: c1, step: '构建' } }
|
|
196
220
|
|
|
197
|
-
// 2. rsync(--delete 仅用于前端模块,与原脚本行为一致)
|
|
198
|
-
const local = m.localPath.startsWith('/') ? m.localPath : `${env.projectPath.replace(/\/+$/, '')}/${m.localPath}`
|
|
199
|
-
const rsyncArgs = ['rsync', '-r', '-t', '-z', '-e', sshpass ? `sshpass -e ${sshBase}` : sshBase]
|
|
221
|
+
// 2. 同步:优先 rsync;本机没有时回退 rclone(--delete 仅用于前端模块,与原脚本行为一致)
|
|
200
222
|
const isFront = m.kind === 'front'
|
|
201
|
-
|
|
202
|
-
|
|
223
|
+
// 保留 rsync 的目录尾斜杠语义:dist/ 传内容本身,dist 传 dist 目录
|
|
224
|
+
const isDirLike = /[\\/]$/.test(m.localPath)
|
|
225
|
+
const joined = path.isAbsolute(m.localPath) ? m.localPath : path.join(env.projectPath, m.localPath)
|
|
226
|
+
const local = joined.replace(/[\\/]+$/, '') + (isDirLike ? '/' : '')
|
|
203
227
|
const rsyncEnv = sshpass ? { ...process.env, SSHPASS: env.ssh.password } : process.env
|
|
204
|
-
|
|
228
|
+
let c2
|
|
229
|
+
if (rclone) {
|
|
230
|
+
const args = buildRcloneArgs(env, m, local, rclone)
|
|
231
|
+
c2 = await runStep(`[${m.name}] rclone ${isFront ? 'sync' : 'copy'} ${local} → :sftp:${m.remotePath}${isFront ? '(镜像 --delete)' : ''}`, [rclone.bin, ...args], {})
|
|
232
|
+
} else {
|
|
233
|
+
const rsyncArgs = ['rsync', '-r', '-t', '-z', '-e', sshpass ? `sshpass -e ${sshBase}` : sshBase]
|
|
234
|
+
if (isFront) rsyncArgs.push('--delete', '--exclude=.user.ini') // 排除宝塔 .user.ini(chattr +i 锁定,--delete 无法删除且会导致退出码非 0)
|
|
235
|
+
rsyncArgs.push(local, `${dest}:${m.remotePath}`)
|
|
236
|
+
c2 = await runStep(`[${m.name}] rsync ${local} → ${dest}:${m.remotePath}${isFront ? '(--delete)' : ''}`, rsyncArgs, { env: rsyncEnv })
|
|
237
|
+
}
|
|
205
238
|
if (c2 !== 0) { broadcast(`[${m.name}] 同步失败,退出码 ${c2},中止部署\n`); return { code: c2, step: '同步' } }
|
|
206
239
|
|
|
207
240
|
// 3. ssh 远程命令
|
|
@@ -223,17 +256,43 @@ export async function deployModule(env, m, sshBase, dest, sshpass) {
|
|
|
223
256
|
async function runDeployment(env, mods) {
|
|
224
257
|
let code = 1
|
|
225
258
|
try {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
259
|
+
// 预检本地工具,缺失时给出可操作的提示(避免到同步步骤才报 127 启动失败)
|
|
260
|
+
// 同步优先 rsync,缺失回退 rclone(SFTP);ssh 仅 rsync 传输或有远程命令时需要
|
|
261
|
+
const hasRsync = await which('rsync')
|
|
262
|
+
const rcloneBin = hasRsync ? null : await which('rclone')
|
|
263
|
+
const needSsh = mods.some(m => m.enabled && m.postCommand.trim())
|
|
264
|
+
const missing = []
|
|
265
|
+
if (!hasRsync && !rcloneBin) missing.push('rsync(或 rclone)')
|
|
266
|
+
if ((hasRsync || needSsh) && !(await which('ssh'))) missing.push('ssh')
|
|
267
|
+
// sshpass:rsync 密码传输恒需要;rclone 模式自身支持密码,仅「密码 + 远程命令」时才需要
|
|
268
|
+
const needSshpass = !!env.ssh.password && (hasRsync || needSsh)
|
|
269
|
+
const sshpass = needSshpass ? await which('sshpass') : null
|
|
270
|
+
if (missing.length) {
|
|
271
|
+
broadcast(`错误: 本机未找到 ${missing.join('、')}(须在 PATH 中)。${IS_WIN ? 'Windows 可安装 rclone 替代 rsync(https://rclone.org),ssh 用系统自带 OpenSSH' : '请先安装 rsync(或 rclone)与 OpenSSH'}\n`)
|
|
272
|
+
code = 127
|
|
273
|
+
} else if (needSshpass && !sshpass) {
|
|
274
|
+
broadcast(IS_WIN
|
|
275
|
+
? '错误: 远程命令 + SSH 密码需要 sshpass,而 Windows 无此工具;请改用密钥认证(SSH 自定义选项填 -i 私钥路径,并清空密码),或移除远程命令让 rclone 直接同步\n'
|
|
276
|
+
: '错误: 配置了 SSH 密码但本机没有 sshpass。请先安装: brew install hudochenkov/sshpass/sshpass\n')
|
|
229
277
|
code = 127
|
|
230
278
|
} else {
|
|
231
279
|
const sshBase = `ssh -p ${env.ssh.port}${env.ssh.options ? ' ' + env.ssh.options : ''}`
|
|
232
280
|
const dest = `${env.ssh.user}@${env.ssh.host}`
|
|
281
|
+
// rclone 密码认证走 obscure 混淆值(失败立即中止,避免到同步步骤才报错)
|
|
282
|
+
let rclone = null
|
|
283
|
+
if (rcloneBin) {
|
|
284
|
+
const pass = env.ssh.password ? await rcloneObscure(rcloneBin, env.ssh.password) : null
|
|
285
|
+
if (env.ssh.password && !pass) {
|
|
286
|
+
broadcast('错误: rclone obscure 密码处理失败,无法使用密码认证\n')
|
|
287
|
+
code = 127
|
|
288
|
+
return finishDeploy(code) // 尚未构建,无需停 daemon
|
|
289
|
+
}
|
|
290
|
+
rclone = { bin: rcloneBin, pass }
|
|
291
|
+
}
|
|
233
292
|
code = 0
|
|
234
293
|
for (const m of mods) {
|
|
235
294
|
if (!m.enabled) { broadcast(`[跳过] ${m.name} 已禁用\n`); continue }
|
|
236
|
-
const res = await deployModule(env, m, sshBase, dest, sshpass)
|
|
295
|
+
const res = await deployModule(env, m, sshBase, dest, sshpass, rclone)
|
|
237
296
|
if (res.code !== 0) {
|
|
238
297
|
if (running?.record) { running.record.failedModule = m.name; running.record.failedStep = res.step }
|
|
239
298
|
code = res.code
|
package/server/local-tools.js
CHANGED
|
@@ -16,13 +16,22 @@ function run(cmd, args, options = {}) {
|
|
|
16
16
|
})
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
const IS_WIN = process.platform === 'win32'
|
|
20
|
+
|
|
21
|
+
// 等价旧 Bun.which:找到返回绝对路径,找不到返回 null(Windows 用 where)
|
|
20
22
|
export async function which(command) {
|
|
21
|
-
const { code, stdout } = await run('which', [command])
|
|
22
|
-
const found = stdout.trim()
|
|
23
|
+
const { code, stdout } = await run(IS_WIN ? 'where' : 'which', [command])
|
|
24
|
+
const found = stdout.split('\n')[0].trim()
|
|
23
25
|
return code === 0 && found ? found : null
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
// rclone obscure 密码混淆(--sftp-pass 只接受混淆值);失败返回 null
|
|
29
|
+
export async function rcloneObscure(bin, password) {
|
|
30
|
+
const { code, stdout } = await run(bin, ['obscure', password])
|
|
31
|
+
const v = stdout.trim()
|
|
32
|
+
return code === 0 && v ? v : null
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
// 列出目录所在 git 仓库的全部 worktree(含主工作区);非 git 仓库/失败返回空数组
|
|
27
36
|
export async function listWorktrees(dir) {
|
|
28
37
|
const worktrees = []
|
|
@@ -98,16 +107,32 @@ export async function listJdks() {
|
|
|
98
107
|
for (const v of vers) found.add(path.join(vdir, v, 'libexec/openjdk.jdk/Contents/Home'))
|
|
99
108
|
}
|
|
100
109
|
} catch {}
|
|
110
|
+
// Windows:JAVA_HOME + 常见厂商安装目录(Oracle / Temurin / Microsoft / Zulu / Corretto)
|
|
111
|
+
if (IS_WIN) {
|
|
112
|
+
if (process.env.JAVA_HOME) found.add(process.env.JAVA_HOME)
|
|
113
|
+
const roots = [process.env.ProgramFiles, process.env['ProgramFiles(x86)']].filter(Boolean)
|
|
114
|
+
const vendors = ['Java', 'Eclipse Adoptium', 'Eclipse Foundation', 'Microsoft', 'Zulu', 'Amazon Corretto']
|
|
115
|
+
for (const root of roots) {
|
|
116
|
+
for (const vendor of vendors) {
|
|
117
|
+
try { for (const n of await fsp.readdir(path.join(root, vendor))) found.add(path.join(root, vendor, n)) } catch {}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
101
121
|
// 只保留 bin/java 真实存在者,生成友好短名
|
|
102
122
|
const jdks = []
|
|
103
|
-
for (const p of found) if (await fileExists(
|
|
104
|
-
|
|
123
|
+
for (const p of found) if (await fileExists(path.join(p, 'bin', IS_WIN ? 'java.exe' : 'java'))) {
|
|
124
|
+
// 标签推导统一用正斜杠视图(Windows 反斜杠路径也能复用同一套正则)
|
|
125
|
+
const pv = p.replace(/\\/g, '/')
|
|
126
|
+
const m = pv.match(/\/JavaVirtualMachines\/([^/]+)\.jdk\/Contents\/Home$/)
|
|
105
127
|
let label
|
|
106
128
|
if (m) label = (m[1].match(/\d+(?:\.\d+)*/) || [])[1] || m[1]
|
|
107
|
-
else if (
|
|
108
|
-
const v = (
|
|
129
|
+
else if (pv.includes('/opt/homebrew/')) {
|
|
130
|
+
const v = (pv.match(/openjdk@?(\d+)/) || [])[1] || (pv.match(/\/(\d+(?:\.\d+)+)\/libexec/) || [])[1]
|
|
109
131
|
label = v ? 'homebrew-' + v : 'homebrew'
|
|
110
|
-
} else
|
|
132
|
+
} else if (/^[a-zA-Z]:\//.test(pv)) {
|
|
133
|
+
const segs = pv.split('/').filter(Boolean)
|
|
134
|
+
label = segs.pop() || pv
|
|
135
|
+
} else label = pv.split('/').filter(Boolean).pop()
|
|
111
136
|
jdks.push({ label, path: p })
|
|
112
137
|
}
|
|
113
138
|
// 短名去重:同名者追加版本/opt-cellar 段区分
|
|
@@ -139,7 +164,7 @@ export async function listJdks() {
|
|
|
139
164
|
export async function browseDirectory(args = {}) {
|
|
140
165
|
let p = typeof args.path === 'string' ? args.path : ''
|
|
141
166
|
const showHidden = args.hidden === true || args.hidden === '1' || args.hidden === 1
|
|
142
|
-
if (!p) p = process.env.HOME || '/'
|
|
167
|
+
if (!p) p = process.env.HOME || (IS_WIN ? (process.env.USERPROFILE || 'C:\\') : '/')
|
|
143
168
|
if (!path.isAbsolute(p)) throw new Error('必须为绝对路径')
|
|
144
169
|
p = path.resolve(p)
|
|
145
170
|
try {
|
package/server/rpc.js
CHANGED
|
@@ -4,6 +4,11 @@ import { getHistory, replaceHistory, HIST_LIMIT } from './history.js'
|
|
|
4
4
|
import { activeRecord } from './deployment.js'
|
|
5
5
|
import { listJdks, listWorktrees, readGitInfo, browseDirectory, which } from './local-tools.js'
|
|
6
6
|
|
|
7
|
+
// sshpass 无 Windows 版本;警告文案按平台给出可操作的下一步(保存/导入两处共用)
|
|
8
|
+
const SSHPASS_HINT = process.platform === 'win32'
|
|
9
|
+
? '但本机没有 sshpass(Windows 无此工具),部署时无法使用密码认证;请改用密钥认证(SSH 自定义选项填 -i 私钥路径,并清空密码)'
|
|
10
|
+
: '但本机未安装 sshpass,部署时无法使用密码认证。请先执行: brew install hudochenkov/sshpass/sshpass'
|
|
11
|
+
|
|
7
12
|
// JSON-RPC 方法表:与旧 server.ts 的 /api/* 逐一对齐(字段名不变)
|
|
8
13
|
const methods = {
|
|
9
14
|
config: () => {
|
|
@@ -27,7 +32,7 @@ const methods = {
|
|
|
27
32
|
await saveEnvs()
|
|
28
33
|
let warning = ''
|
|
29
34
|
if (env.ssh.password && !(await which('sshpass'))) {
|
|
30
|
-
warning = '
|
|
35
|
+
warning = '已保存,' + SSHPASS_HINT
|
|
31
36
|
}
|
|
32
37
|
return { envs: getEnvs(), warning }
|
|
33
38
|
},
|
|
@@ -68,7 +73,7 @@ const methods = {
|
|
|
68
73
|
const touched = new Set([...added, ...overwritten])
|
|
69
74
|
let warning = ''
|
|
70
75
|
if (getEnvs().some(e => touched.has(e.id) && e.ssh.password) && !(await which('sshpass'))) {
|
|
71
|
-
warning = '
|
|
76
|
+
warning = '已导入,' + SSHPASS_HINT
|
|
72
77
|
}
|
|
73
78
|
return { envs: getEnvs(), added, overwritten, warning }
|
|
74
79
|
},
|