dsh-app-deploy 0.2.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-app-deploy",
3
- "version": "0.2.0",
4
- "description": "DSH 插件:按环境编排并执行 Gradle 构建、rsync 同步与 SSH 远程命令的部署面板,实时推送日志并保留执行记录。",
3
+ "version": "0.3.0",
4
+ "description": "DSH 插件:按环境编排并执行构建(Gradle/Maven/前端)、多 SSH 目标发布、rsync/rclone 同步与远程命令的部署面板,支持连接测试,实时推送日志并保留执行记录。",
5
5
  "repository": "btboys/dsh-app-deploy",
6
6
  "type": "module",
7
7
  "icon": "./icon.svg",
package/server/config.js CHANGED
@@ -11,22 +11,57 @@ let envs = []
11
11
  const MODULE_NAME = { bsFront: 'bs 前端', bsServer: 'bs 服务端', adminFront: 'admin 前端', adminServer: 'admin 服务端' }
12
12
  const MODULE_ORDER = ['bsFront', 'bsServer', 'adminFront', 'adminServer']
13
13
 
14
+ // 旧 gradle 三字段(gradleTask/key/extraArgs)拼接为通用构建命令
15
+ function cmdFromGradle(g, key, extra) {
16
+ return ['./gradlew', g, key ? `-Pkey=${key}` : '', extra || ''].filter(Boolean).join(' ')
17
+ }
18
+
14
19
  function defaultModules() {
15
20
  return [
16
- { id: 'bsFront', name: 'bs 前端', kind: 'front', enabled: true, gradleTask: 'client:client-front:dist', key: '', extraArgs: '', localPath: 'client/client-front/dist/', remotePath: '', postCommand: '', frontEnv: '' },
17
- { id: 'bsServer', name: 'bs 服务端', kind: 'server', enabled: true, gradleTask: 'client:client-server:bootJar', key: '', extraArgs: '', localPath: 'client/client-server/build/libs/financial-v5.jar', remotePath: '', postCommand: '', frontEnv: '' },
18
- { id: 'adminFront', name: 'admin 前端', kind: 'front', enabled: true, gradleTask: 'admin:admin-front:dist', key: '', extraArgs: '', localPath: 'admin/admin-front/dist/', remotePath: '', postCommand: '', frontEnv: '' },
19
- { id: 'adminServer', name: 'admin 服务端', kind: 'server', enabled: true, gradleTask: 'admin:admin-server:bootJar', key: '', extraArgs: '', localPath: 'admin/admin-server/build/libs/financial-admin-v5.jar', remotePath: '', postCommand: '', frontEnv: '' },
21
+ { id: 'bsFront', name: 'bs 前端', kind: 'front', enabled: true, buildCmd: './gradlew client:client-front:dist', localPath: 'client/client-front/dist/', remotePath: '', postCommand: '', frontEnv: '' },
22
+ { id: 'bsServer', name: 'bs 服务端', kind: 'server', enabled: true, buildCmd: './gradlew client:client-server:bootJar', localPath: 'client/client-server/build/libs/financial-v5.jar', remotePath: '', postCommand: '', frontEnv: '' },
23
+ { id: 'adminFront', name: 'admin 前端', kind: 'front', enabled: true, buildCmd: './gradlew admin:admin-front:dist', localPath: 'admin/admin-front/dist/', remotePath: '', postCommand: '', frontEnv: '' },
24
+ { id: 'adminServer', name: 'admin 服务端', kind: 'server', enabled: true, buildCmd: './gradlew admin:admin-server:bootJar', localPath: 'admin/admin-server/build/libs/financial-admin-v5.jar', remotePath: '', postCommand: '', frontEnv: '' },
20
25
  ]
21
26
  }
22
27
 
28
+ function defaultSsh() {
29
+ return { id: 'default', name: '默认', host: '', port: 22, user: 'root', password: '', keyFile: '', options: '' }
30
+ }
31
+
23
32
  function templateEnv() {
24
- return { id: '', name: '', note: '', group: '', projectPath: defaultProjectPath, jdkHome: '', expTime: '', ssh: { host: '', port: 22, user: 'root', password: '', options: '' }, modules: defaultModules() }
33
+ return { id: '', name: '', note: '', group: '', projectPath: defaultProjectPath, jdkHome: '', nodeHome: '', nodeOptions: '', pmPath: '', buildEnv: '', expTime: '', sshs: [defaultSsh()], modules: defaultModules() }
25
34
  }
26
35
 
27
36
  // 把 modules 归一化为数组;record 旧格式自动转数组(id 保持 bsFront 等,name 映射),frontEnv.bs/admin 迁移进对应 front 模块
28
37
  function isNewSchema(e) {
29
- return !!e && typeof e === 'object' && !!e.ssh && typeof e.ssh === 'object'
38
+ return !!e && typeof e === 'object' && ((!!e.ssh && typeof e.ssh === 'object') || Array.isArray(e.sshs))
39
+ }
40
+
41
+ function normalizeSsh(raw, i) {
42
+ return {
43
+ id: String(raw?.id || (i === 0 ? 'default' : 'ssh' + (i + 1))),
44
+ name: String(raw?.name || (i === 0 ? '默认' : 'SSH ' + (i + 1))),
45
+ host: String(raw?.host ?? ''),
46
+ port: Number(raw?.port) || 22,
47
+ user: String(raw?.user ?? 'root'),
48
+ password: String(raw?.password ?? ''),
49
+ keyFile: String(raw?.keyFile ?? ''),
50
+ options: String(raw?.options ?? ''),
51
+ }
52
+ }
53
+
54
+ // SSH 目标列表:旧格式 env.ssh 单对象自动迁移为单元素数组
55
+ function toSshArray(e) {
56
+ if (Array.isArray(e?.sshs) && e.sshs.length) return e.sshs.map(normalizeSsh)
57
+ if (e?.ssh && typeof e.ssh === 'object') return [normalizeSsh(e.ssh, 0)]
58
+ return [defaultSsh()]
59
+ }
60
+
61
+ // 模块的发布目标:sshId 为空或不存在时回落到第一个目标
62
+ export function targetFor(env, m) {
63
+ const list = Array.isArray(env?.sshs) && env.sshs.length ? env.sshs : [defaultSsh()]
64
+ return list.find((s) => s.id === m?.sshId) || list[0]
30
65
  }
31
66
 
32
67
  // 把 modules 归一化为数组;record 旧格式自动转数组(id 保持 bsFront 等,name 映射),frontEnv.bs/admin 迁移进对应 front 模块
@@ -41,9 +76,8 @@ function toModArray(mods, fe) {
41
76
  name: String(raw?.name || MODULE_NAME[id] || id),
42
77
  kind: (raw?.kind === 'server' ? 'server' : raw?.kind === 'front' ? 'front' : (id.endsWith('Front') ? 'front' : 'server')),
43
78
  enabled: raw?.enabled !== false,
44
- gradleTask: String(raw?.gradleTask || ''),
45
- key: String(raw?.key || ''),
46
- extraArgs: String(raw?.extraArgs || ''),
79
+ buildCmd: String(raw?.buildCmd || cmdFromGradle(raw?.gradleTask, raw?.key, raw?.extraArgs)),
80
+ sshId: String(raw?.sshId || ''),
47
81
  localPath: String(raw?.localPath || ''),
48
82
  remotePath: String(raw?.remotePath || ''),
49
83
  postCommand: String(raw?.postCommand || ''),
@@ -59,7 +93,8 @@ function toModArray(mods, fe) {
59
93
  out.push({
60
94
  id: mk, name: MODULE_NAME[mk] || mk, kind: mk.endsWith('Front') ? 'front' : 'server',
61
95
  enabled: m.enabled !== false,
62
- gradleTask: String(m.gradleTask || ''), key: String(m.key || ''), extraArgs: String(m.extraArgs || ''),
96
+ buildCmd: String(m.buildCmd || cmdFromGradle(m.gradleTask, m.key, m.extraArgs)),
97
+ sshId: String(m.sshId || ''),
63
98
  localPath: String(m.localPath || ''), remotePath: String(m.remotePath || ''), postCommand: String(m.postCommand || ''),
64
99
  frontEnv: feText,
65
100
  })
@@ -74,19 +109,23 @@ function normalizeEnv(e) {
74
109
  group: e.group ?? '',
75
110
  projectPath: e.projectPath || defaultProjectPath,
76
111
  jdkHome: e.jdkHome ?? '',
112
+ nodeHome: e.nodeHome ?? '',
113
+ nodeOptions: e.nodeOptions ?? '',
114
+ pmPath: e.pmPath ?? '',
115
+ buildEnv: e.buildEnv ?? '',
77
116
  expTime: e.expTime ?? '',
78
- ssh: { host: e.ssh?.host ?? '', port: Number(e.ssh?.port) || 22, user: e.ssh?.user ?? 'root', password: e.ssh?.password ?? '', options: e.ssh?.options ?? '' },
117
+ sshs: toSshArray(e),
79
118
  modules: toModArray(e.modules, { bs: e.frontEnv?.bs, admin: e.frontEnv?.admin }),
80
119
  }
81
- // 迁移(幂等):把 server 模块 extraArgs 里的 -PexpTime=xxx 提取到 env.expTime 并从 extraArgs 移除
120
+ // 迁移(幂等):把 server 模块构建命令里的 -PexpTime=xxx 提取到 env.expTime 并移除
82
121
  let extracted = ''
83
122
  for (const m of env.modules) {
84
- if (m.kind !== 'server' || !m.extraArgs) continue
85
- const kept = m.extraArgs.trim().split(/\s+/).filter(p => {
123
+ if (m.kind !== 'server' || !m.buildCmd) continue
124
+ const kept = m.buildCmd.trim().split(/\s+/).filter(p => {
86
125
  if (p.startsWith('-PexpTime=')) { if (!extracted) extracted = p.slice(10); return false }
87
126
  return true
88
127
  })
89
- m.extraArgs = kept.join(' ')
128
+ m.buildCmd = kept.join(' ')
90
129
  }
91
130
  if (extracted && !env.expTime) env.expTime = extracted
92
131
  return env
@@ -105,7 +144,8 @@ async function loadEnvs() {
105
144
  for (const e of raw) {
106
145
  if (isNewSchema(e)) {
107
146
  const r = e
108
- if (!Array.isArray(r.modules) || r.modules.some(m => m?.extraArgs?.toString().includes('-PexpTime='))) migrated = true
147
+ if (!Array.isArray(r.modules) || r.modules.some(m => m && (m.gradleTask !== undefined || m.extraArgs?.toString().includes('-PexpTime=')))) migrated = true
148
+ if (!Array.isArray(r.sshs)) migrated = true // env.ssh 单对象 → sshs 数组
109
149
  out.push(normalizeEnv(r))
110
150
  continue
111
151
  }
@@ -156,9 +196,8 @@ export function validateFrontEnv(s) {
156
196
  export function frontEnvFile(e, m) {
157
197
  const p = (e.projectPath || defaultProjectPath || '').replace(/[\\/]+$/, '')
158
198
  if (!p) return null
159
- const key = m.key ? '.' + m.key : ''
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}`)
199
+ if (m.id === 'bsFront') return path.join(p, 'client/client-front', '.env')
200
+ if (m.id === 'adminFront') return path.join(p, 'admin/admin-front', '.env')
162
201
  return null
163
202
  }
164
203
 
@@ -171,12 +210,32 @@ export function validateEnv(e) {
171
210
  if (BAD_PATH.test(e.projectPath)) errs.push('项目路径含非法字符(禁止空格/引号/; & | ` $ 等)')
172
211
  if (e.jdkHome && !isAbsPath(e.jdkHome)) errs.push('JDK 路径必须是绝对路径')
173
212
  if (e.jdkHome && BAD_PATH.test(e.jdkHome)) errs.push('JDK 路径含非法字符')
174
- const s = e.ssh
175
- if (!/^[a-zA-Z0-9.-]+$/.test(s.host)) errs.push('SSH host 非法(仅字母/数字/./-)')
176
- if (!Number.isInteger(s.port) || s.port < 1 || s.port > 65535) errs.push('SSH port 必须是 1-65535 的整数')
177
- if (!/^[a-zA-Z0-9._-]+$/.test(s.user)) errs.push('SSH user 非法')
178
- if (BAD_CMD.test(s.options)) errs.push('SSH 自定义选项含非法字符')
179
- if (s.password.includes('\n')) errs.push('SSH 密码不能包含换行')
213
+ if (e.nodeHome && !isAbsPath(e.nodeHome)) errs.push('Node 路径必须是绝对路径')
214
+ if (e.nodeHome && BAD_PATH.test(e.nodeHome)) errs.push('Node 路径含非法字符')
215
+ if (e.nodeOptions && BAD_CMD.test(e.nodeOptions)) errs.push('Node options 含非法字符')
216
+ if (e.pmPath && !isAbsPath(e.pmPath)) errs.push('包管理器路径必须是绝对路径(如 /opt/homebrew/bin/pnpm)')
217
+ if (e.pmPath && BAD_PATH.test(e.pmPath)) errs.push('包管理器路径含非法字符')
218
+ if (e.buildEnv) {
219
+ const be = validateFrontEnv(e.buildEnv)
220
+ if (be) errs.push('构建环境变量:' + be)
221
+ }
222
+ const sshList = Array.isArray(e.sshs) && e.sshs.length ? e.sshs : []
223
+ if (!sshList.length) errs.push('至少需要一个 SSH 目标')
224
+ const seenSshId = {}
225
+ for (const s of sshList) {
226
+ const tag = s.name || s.id
227
+ if (!/^[A-Za-z0-9-]+$/.test(s.id)) errs.push(`SSH 目标「${tag}」: ID 只能包含字母/数字/连字符`)
228
+ else if (seenSshId[s.id]) errs.push(`SSH 目标「${tag}」: ID「${s.id}」重复`)
229
+ seenSshId[s.id] = true
230
+ if (!s.name.trim()) errs.push('SSH 目标显示名必填')
231
+ if (!/^[a-zA-Z0-9.-]+$/.test(s.host)) errs.push(`SSH 目标「${tag}」: host 非法(仅字母/数字/./-)`)
232
+ if (!Number.isInteger(s.port) || s.port < 1 || s.port > 65535) errs.push(`SSH 目标「${tag}」: port 必须是 1-65535 的整数`)
233
+ if (!/^[a-zA-Z0-9._-]+$/.test(s.user)) errs.push(`SSH 目标「${tag}」: user 非法`)
234
+ if (BAD_CMD.test(s.options)) errs.push(`SSH 目标「${tag}」: 自定义选项含非法字符`)
235
+ if (s.password.includes('\n')) errs.push(`SSH 目标「${tag}」: 密码不能包含换行`)
236
+ if (s.keyFile && !isAbsPath(s.keyFile)) errs.push(`SSH 目标「${tag}」: 私钥路径必须是绝对路径`)
237
+ if (s.keyFile && BAD_PATH.test(s.keyFile)) errs.push(`SSH 目标「${tag}」: 私钥路径含非法字符`)
238
+ }
180
239
  if (e.expTime && !/^\d{4}-\d{2}-\d{2}$/.test(e.expTime)) errs.push('过期时间必须是 YYYY-MM-DD')
181
240
  if (!Array.isArray(e.modules) || !e.modules.length) errs.push('至少需要一个模块')
182
241
  const seenId = {}
@@ -197,9 +256,9 @@ export function validateEnv(e) {
197
256
  } catch { errs.push(`${m.name || m.id}: 健康检查 URL 必须是 HTTP/HTTPS 地址,且不能包含用户名或密码`) }
198
257
  }
199
258
  if (!m.enabled) continue
200
- if (!/^[a-zA-Z0-9:_-]+$/.test(m.gradleTask)) errs.push(`${m.name || m.id}: gradle 任务非法`)
201
- if (m.key && !/^[a-zA-Z0-9._-]+$/.test(m.key)) errs.push(`${m.name || m.id}: -Pkey 非法`)
202
- if (m.extraArgs && BAD_CMD.test(m.extraArgs)) errs.push(`${m.name || m.id}: 额外参数含非法字符`)
259
+ if (m.sshId && !sshList.some((s) => s.id === m.sshId)) errs.push(`${m.name || m.id}: SSH 目标「${m.sshId}」不存在`)
260
+ if (!m.buildCmd.trim()) errs.push(`${m.name || m.id}: 构建命令必填(如 ./gradlew …、mvn …、npm run build)`)
261
+ else if (BAD_CMD.test(m.buildCmd)) errs.push(`${m.name || m.id}: 构建命令含非法字符(禁止引号/; & | \` $ 等)`)
203
262
  if (!m.localPath) errs.push(`${m.name || m.id}: 本地产物路径必填`)
204
263
  else if (BAD_PATH.test(m.localPath)) errs.push(`${m.name || m.id}: 本地产物路径含非法字符`)
205
264
  if (!m.remotePath) errs.push(`${m.name || m.id}: 远程目标路径必填`)
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { promises as fsp } from 'node:fs'
2
+ import { promises as fsp, existsSync } from 'node:fs'
3
3
  import path from 'node:path'
4
- import { getEnvs } from './config.js'
4
+ import { getEnvs, targetFor } from './config.js'
5
5
  import { getHistory, saveHistory, LOGTAIL_CHARS } from './history.js'
6
6
  import { listWorktrees, which, rcloneObscure } from './local-tools.js'
7
7
  import { checkHealth } from './health-check.js'
@@ -79,10 +79,54 @@ function runStep(label, args, opts = {}) {
79
79
  })
80
80
  }
81
81
 
82
+ // 测试 SSH 连通性:BatchMode 禁止交互询问(密码仍走 sshpass),10 秒超时;返回 { ok, ms?, error? }
83
+ export async function testSsh(target) {
84
+ if (!/^[a-zA-Z0-9.-]+$/.test(target.host || '')) return { ok: false, error: 'host 非法' }
85
+ if (!/^[a-zA-Z0-9._-]+$/.test(target.user || '')) return { ok: false, error: 'user 非法' }
86
+ const port = Number(target.port) || 22
87
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return { ok: false, error: 'port 非法' }
88
+ const args = ['-p', String(port), '-o', 'ConnectTimeout=8', '-o', 'StrictHostKeyChecking=accept-new']
89
+ // BatchMode 禁止交互询问,但会同时禁掉密码提示导致 sshpass 失效——仅无密码(密钥/agent)时使用
90
+ if (!target.password) args.push('-o', 'BatchMode=yes')
91
+ if (target.keyFile) args.push('-i', target.keyFile)
92
+ if (target.options) args.push(...String(target.options).trim().split(/\s+/).filter(Boolean))
93
+ args.push(`${target.user}@${target.host}`, 'true')
94
+ let cmd = 'ssh', cargs = args, env = process.env
95
+ if (target.password) {
96
+ if (!(await which('sshpass'))) return { ok: false, error: '本机未安装 sshpass,无法以密码测试(留空密码改用密钥/agent 测试)' }
97
+ cmd = 'sshpass'
98
+ cargs = ['-e', 'ssh', ...args]
99
+ env = { ...process.env, SSHPASS: target.password }
100
+ }
101
+ const start = Date.now()
102
+ return new Promise((resolve) => {
103
+ let child
104
+ try {
105
+ child = spawn(cmd, cargs, { env, shell: IS_WIN, stdio: ['ignore', 'ignore', 'pipe'] })
106
+ } catch (err) {
107
+ return resolve({ ok: false, error: String(err) })
108
+ }
109
+ let errOut = ''
110
+ const timer = setTimeout(() => {
111
+ child.kill()
112
+ resolve({ ok: false, error: '连接超时(10s)' })
113
+ }, 10000)
114
+ child.stderr.on('data', (c) => { errOut += c.toString('utf8') })
115
+ child.once('error', (err) => { clearTimeout(timer); resolve({ ok: false, error: String(err) }) })
116
+ child.once('close', (code) => {
117
+ clearTimeout(timer)
118
+ if (code === 0) resolve({ ok: true, ms: Date.now() - start })
119
+ else resolve({ ok: false, error: (errOut.trim().split('\n').pop() || `退出码 ${code}`).slice(0, 300) })
120
+ })
121
+ })
122
+ }
123
+
82
124
  // 部署结束后尽力停掉 Gradle daemon:daemon 是常驻 JVM(默认空闲 3 小时才退出),
83
125
  // 多次构建复用后类加载器/缓存累积会把堆耗尽(报 "Daemon will expire ... running out of JVM heap space")。
84
126
  // 无 daemon 时 --stop 也返回 0;任何失败都忽略,不影响部署结果。
85
127
  async function stopGradleDaemon(env) {
128
+ // 非 gradle 项目没有 wrapper,跳过(也避免误播"已停止")
129
+ if (!existsSync(path.join(env.projectPath, IS_WIN ? 'gradlew.bat' : 'gradlew'))) return
86
130
  try {
87
131
  const stopEnv = env.jdkHome ? { ...process.env, JAVA_HOME: env.jdkHome } : process.env
88
132
  await new Promise((resolve) => {
@@ -178,35 +222,54 @@ async function cleanBuildDir(env, m) {
178
222
 
179
223
  // 组装 rclone 同步参数(SFTP 后端):sync 自带 --delete 用于前端镜像,copy 不删用于服务端;
180
224
  // 密码用 obscure 混淆值(只进 spawn 参数,不进日志标签);密钥取 SSH 自定义选项里的 -i
181
- export function buildRcloneArgs(env, m, local, rclone) {
225
+ export function buildRcloneArgs(target, m, local, rclone) {
182
226
  const isFront = m.kind === 'front'
183
227
  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,
228
+ '--sftp-host', target.host, '--sftp-port', String(target.port), '--sftp-user', target.user,
185
229
  '--stats-one-line', '--stats', '10s', '-v']
186
230
  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])
231
+ // 密码:优先按目标密码查 obscure 缓存(多目标部署),否则用调用方直接给的 pass
232
+ const pass = (rclone.cache && target.password) ? rclone.cache.get(target.password) : rclone.pass
233
+ if (pass) args.push('--sftp-pass', pass)
234
+ // 私钥:专用 keyFile 字段优先,兼容旧的 options 里 -i 写法
235
+ const key = target.keyFile || ((target.options || '').match(/(?:^|\s)-i\s+(\S+)/) || [])[1]
236
+ // 与 ssh/rsync 路径行为一致:私钥可与密码同时存在(rclone 自身会依次尝试)
237
+ if (key) args.push('--sftp-key-file', key)
190
238
  return args
191
239
  }
192
240
 
193
241
  // 返回退出码与失败阶段(清理/构建/同步/远程命令),用于执行记录
194
242
  // rclone 非空时用 rclone(SFTP)替代 rsync 同步;rsyncEnv 同时承载 sshpass 的 SSHPASS
195
- export async function deployModule(env, m, sshBase, dest, sshpass, rclone = null) {
243
+ // target 为模块选中的 SSH 目标(env.sshs 之一)
244
+ export async function deployModule(env, m, target, sshpass, rclone = null) {
245
+ const sshBase = `ssh -p ${target.port}${target.keyFile ? ' -i ' + target.keyFile : ''}${target.options ? ' ' + target.options : ''}`
246
+ const dest = `${target.user}@${target.host}`
196
247
  // 0. 清理构建目录(恢复原 update-*.sh 行为)
197
248
  const c0 = await cleanBuildDir(env, m)
198
249
  if (c0 !== 0) return { code: c0, step: '清理' }
199
250
 
200
- // 1. 构建
201
- const buildArgs = [GRADLEW, m.gradleTask]
202
- if (m.key) buildArgs.push(`-Pkey=${m.key}`)
203
- if (m.extraArgs) buildArgs.push(...m.extraArgs.trim().split(/\s+/).filter(Boolean))
204
- if (m.kind === 'server' && env.expTime) buildArgs.push(`-PexpTime=${env.expTime}`)
205
- const jdkNote = env.jdkHome ? `(JAVA_HOME=${env.jdkHome})` : ''
206
- const envForGradle = env.jdkHome ? { ...process.env, JAVA_HOME: env.jdkHome } : { ...process.env }
207
- // 前端模块:把 frontEnv(KEY=VALUE 多行)注入子进程 env;Vite loadEnv 对 process.env 中 VITE_ 前缀变量优先级最高,会覆盖 .env 文件
208
- if (m.kind === 'front') {
209
- for (const line of (m.frontEnv || '').split('\n')) {
251
+ // 1. 构建:通用命令(./gradlew…/mvn…/npm run build…),按空白切分;校验已禁引号与 shell 元字符,无需 shell 拼接
252
+ const buildArgs = m.buildCmd.trim().split(/\s+/).filter(Boolean)
253
+ // Windows 上 ./gradlew 无法经 cmd 解析,回退为 gradlew.bat
254
+ if (IS_WIN && buildArgs[0] === './gradlew') buildArgs[0] = 'gradlew.bat'
255
+ if (m.kind === 'server' && env.expTime && buildArgs[0].includes('gradle')) buildArgs.push(`-PexpTime=${env.expTime}`)
256
+ const jdkNote = [env.jdkHome ? `JAVA_HOME=${env.jdkHome}` : '', env.nodeHome ? `Node=${env.nodeHome}` : ''].filter(Boolean).join(',')
257
+ const noteSuffix = jdkNote ? `(${jdkNote})` : ''
258
+ const envForGradle = { ...process.env }
259
+ if (env.jdkHome) envForGradle.JAVA_HOME = env.jdkHome
260
+ if (env.nodeHome) {
261
+ // nodeHome 允许指向安装目录或 bin 目录;加入 PATH 后 npm/npx/node 均可用
262
+ const bin = /[\\/]bin$/.test(env.nodeHome) ? env.nodeHome : path.join(env.nodeHome, 'bin')
263
+ envForGradle.PATH = `${bin}${IS_WIN ? ';' : ':'}${envForGradle.PATH || ''}`
264
+ }
265
+ if (env.pmPath) {
266
+ // 包管理器(pnpm/yarn 全局 bin 常在 Node 目录之外):所在目录加入 PATH 首位
267
+ envForGradle.PATH = `${path.dirname(env.pmPath)}${IS_WIN ? ';' : ':'}${envForGradle.PATH || ''}`
268
+ }
269
+ if (env.nodeOptions) envForGradle.NODE_OPTIONS = env.nodeOptions
270
+ // 注入 KEY=VALUE 行到构建子进程 env;先环境级 buildEnv,后模块级 frontEnv(后者覆盖前者)
271
+ const injectEnv = (text) => {
272
+ for (const line of (text || '').split('\n')) {
210
273
  const t = line.trim()
211
274
  if (!t || t.startsWith('#')) continue
212
275
  const eq = t.indexOf('=')
@@ -215,7 +278,10 @@ export async function deployModule(env, m, sshBase, dest, sshpass, rclone = null
215
278
  if (/^[A-Za-z0-9_]+$/.test(k)) envForGradle[k] = v
216
279
  }
217
280
  }
218
- const c1 = await runStep(`[${m.name}] 构建: ${buildArgs.join(' ')} ${jdkNote}`, buildArgs, { cwd: env.projectPath, env: envForGradle, shell: IS_WIN })
281
+ injectEnv(env.buildEnv)
282
+ // 前端模块:frontEnv 注入;Vite loadEnv 对 process.env 中 VITE_ 前缀变量优先级最高,会覆盖 .env 文件
283
+ if (m.kind === 'front') injectEnv(m.frontEnv)
284
+ const c1 = await runStep(`[${m.name}] 构建: ${buildArgs.join(' ')} ${noteSuffix}`, buildArgs, { cwd: env.projectPath, env: envForGradle, shell: IS_WIN })
219
285
  if (c1 !== 0) { broadcast(`[${m.name}] 构建失败,退出码 ${c1},中止部署\n`); return { code: c1, step: '构建' } }
220
286
 
221
287
  // 2. 同步:优先 rsync;本机没有时回退 rclone(--delete 仅用于前端模块,与原脚本行为一致)
@@ -224,10 +290,10 @@ export async function deployModule(env, m, sshBase, dest, sshpass, rclone = null
224
290
  const isDirLike = /[\\/]$/.test(m.localPath)
225
291
  const joined = path.isAbsolute(m.localPath) ? m.localPath : path.join(env.projectPath, m.localPath)
226
292
  const local = joined.replace(/[\\/]+$/, '') + (isDirLike ? '/' : '')
227
- const rsyncEnv = sshpass ? { ...process.env, SSHPASS: env.ssh.password } : process.env
293
+ const rsyncEnv = sshpass ? { ...process.env, SSHPASS: target.password } : process.env
228
294
  let c2
229
295
  if (rclone) {
230
- const args = buildRcloneArgs(env, m, local, rclone)
296
+ const args = buildRcloneArgs(target, m, local, rclone)
231
297
  c2 = await runStep(`[${m.name}] rclone ${isFront ? 'sync' : 'copy'} ${local} → :sftp:${m.remotePath}${isFront ? '(镜像 --delete)' : ''}`, [rclone.bin, ...args], {})
232
298
  } else {
233
299
  const rsyncArgs = ['rsync', '-r', '-t', '-z', '-e', sshpass ? `sshpass -e ${sshBase}` : sshBase]
@@ -241,8 +307,9 @@ export async function deployModule(env, m, sshBase, dest, sshpass, rclone = null
241
307
  if (m.postCommand.trim()) {
242
308
  const sshArgs = []
243
309
  if (sshpass) sshArgs.push('sshpass', '-e')
244
- sshArgs.push('ssh', '-p', String(env.ssh.port))
245
- if (env.ssh.options) sshArgs.push(...env.ssh.options.trim().split(/\s+/).filter(Boolean))
310
+ sshArgs.push('ssh', '-p', String(target.port))
311
+ if (target.keyFile) sshArgs.push('-i', target.keyFile)
312
+ if (target.options) sshArgs.push(...target.options.trim().split(/\s+/).filter(Boolean))
246
313
  sshArgs.push(dest, m.postCommand.trim())
247
314
  const c3 = await runStep(`[${m.name}] 远程执行: ${m.postCommand.trim()}`, sshArgs, { env: rsyncEnv })
248
315
  if (c3 !== 0) { broadcast(`[${m.name}] 远程命令失败,退出码 ${c3},中止部署\n`); return { code: c3, step: '远程命令' } }
@@ -261,11 +328,13 @@ async function runDeployment(env, mods) {
261
328
  const hasRsync = await which('rsync')
262
329
  const rcloneBin = hasRsync ? null : await which('rclone')
263
330
  const needSsh = mods.some(m => m.enabled && m.postCommand.trim())
331
+ // 每个启用的模块按其 sshId 解析发布目标(env.sshs 之一)
332
+ const usedTargets = [...new Set(mods.filter(m => m.enabled).map(m => targetFor(env, m)))]
264
333
  const missing = []
265
334
  if (!hasRsync && !rcloneBin) missing.push('rsync(或 rclone)')
266
335
  if ((hasRsync || needSsh) && !(await which('ssh'))) missing.push('ssh')
267
336
  // sshpass:rsync 密码传输恒需要;rclone 模式自身支持密码,仅「密码 + 远程命令」时才需要
268
- const needSshpass = !!env.ssh.password && (hasRsync || needSsh)
337
+ const needSshpass = usedTargets.some(t => t.password) && (hasRsync || needSsh)
269
338
  const sshpass = needSshpass ? await which('sshpass') : null
270
339
  if (missing.length) {
271
340
  broadcast(`错误: 本机未找到 ${missing.join('、')}(须在 PATH 中)。${IS_WIN ? 'Windows 可安装 rclone 替代 rsync(https://rclone.org),ssh 用系统自带 OpenSSH' : '请先安装 rsync(或 rclone)与 OpenSSH'}\n`)
@@ -276,23 +345,26 @@ async function runDeployment(env, mods) {
276
345
  : '错误: 配置了 SSH 密码但本机没有 sshpass。请先安装: brew install hudochenkov/sshpass/sshpass\n')
277
346
  code = 127
278
347
  } else {
279
- const sshBase = `ssh -p ${env.ssh.port}${env.ssh.options ? ' ' + env.ssh.options : ''}`
280
- const dest = `${env.ssh.user}@${env.ssh.host}`
281
- // rclone 密码认证走 obscure 混淆值(失败立即中止,避免到同步步骤才报错)
348
+ // rclone 密码认证走 obscure 混淆值(按密码缓存,多目标各自处理;失败立即中止)
349
+ const passCache = new Map()
282
350
  let rclone = null
283
351
  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 }
352
+ rclone = { bin: rcloneBin, pass: null, cache: passCache }
291
353
  }
292
354
  code = 0
293
355
  for (const m of mods) {
294
356
  if (!m.enabled) { broadcast(`[跳过] ${m.name} 已禁用\n`); continue }
295
- const res = await deployModule(env, m, sshBase, dest, sshpass, rclone)
357
+ const target = targetFor(env, m)
358
+ if (rclone && target.password && !passCache.has(target.password)) {
359
+ const pass = await rcloneObscure(rcloneBin, target.password)
360
+ if (!pass) {
361
+ broadcast(`错误: rclone obscure 密码处理失败(目标 ${target.name}),无法使用密码认证\n`)
362
+ code = 127
363
+ break
364
+ }
365
+ passCache.set(target.password, pass)
366
+ }
367
+ const res = await deployModule(env, m, target, target.password ? sshpass : null, rclone)
296
368
  if (res.code !== 0) {
297
369
  if (running?.record) { running.record.failedModule = m.name; running.record.failedStep = res.step }
298
370
  code = res.code
@@ -332,7 +404,9 @@ export async function handleDeploy(req, res, url) {
332
404
  const ordered = env.modules.filter(m => m.enabled && want.has(m.id))
333
405
  const mods = orderIds.map(id => ordered.find(m => m.id === id)).filter(m => !!m)
334
406
  if (!mods.length) return bad('所选模块在环境中均不可用或已禁用')
335
- if (!env.ssh.host || !env.ssh.user) return bad('SSH 配置不完整')
407
+ // 所选模块涉及的 SSH 目标都必须有 host/user
408
+ const runTargets = [...new Set(mods.map(m => targetFor(env, m)))]
409
+ if (runTargets.some(t => !t.host || !t.user)) return bad('SSH 目标配置不完整')
336
410
 
337
411
  const sseHeaders = { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }
338
412
  const lastEventId = req.headers['last-event-id']
@@ -160,10 +160,77 @@ export async function listJdks() {
160
160
  return out.sort((a, b) => a.label.localeCompare(b.label))
161
161
  }
162
162
 
163
+ // 探测本机包管理器可执行文件(npm/pnpm/yarn),返回 [{label, path}],path 为可执行文件路径
164
+ export async function listPackageManagers() {
165
+ const home = process.env.HOME || process.env.USERPROFILE || ''
166
+ const exts = IS_WIN ? ['.exe', '.cmd', '.ps1', ''] : ['']
167
+ const candidates = []
168
+ const tryBin = async (dir, name) => {
169
+ for (const ext of exts) {
170
+ const p = path.join(dir, name + ext)
171
+ if (await fileExists(p)) { candidates.push({ label: name, path: p }); return }
172
+ }
173
+ }
174
+ // PATH 中的(which 返回可执行文件全路径)
175
+ for (const name of ['npm', 'pnpm', 'yarn']) {
176
+ const w = await which(name).catch(() => '')
177
+ if (w) candidates.push({ label: name, path: w })
178
+ }
179
+ // pnpm/yarn 全局 bin 的常见固定位置(不经 PATH)
180
+ if (home) {
181
+ await tryBin(path.join(home, '.local/share/pnpm'), 'pnpm')
182
+ await tryBin(path.join(home, '.volta/bin'), 'pnpm')
183
+ await tryBin(path.join(home, '.volta/bin'), 'yarn')
184
+ }
185
+ await tryBin('/opt/homebrew/bin', 'pnpm')
186
+ await tryBin('/usr/local/bin', 'pnpm')
187
+ await tryBin('/opt/homebrew/bin', 'yarn')
188
+ // 去重(同路径只留一个)
189
+ const seen = new Set()
190
+ return candidates.filter((c) => (seen.has(c.path) ? false : (seen.add(c.path), true)))
191
+ }
192
+
193
+ // 探测本机已装 Node(nvm / n / fnm / volta / nvm-windows),返回 [{label, path}],path 为版本目录(含 bin/node)
194
+ export async function listNodes() {
195
+ const found = new Set()
196
+ const home = process.env.HOME || process.env.USERPROFILE || ''
197
+ const scan = async (base) => {
198
+ try { for (const n of await fsp.readdir(base)) found.add(path.join(base, n)) } catch {}
199
+ }
200
+ if (home) {
201
+ await scan(path.join(home, '.nvm/versions/node'))
202
+ await scan(path.join(home, '.local/share/fnm/node-versions'))
203
+ await scan(path.join(home, '.volta/tools/image/node'))
204
+ }
205
+ await scan('/usr/local/n/versions/node')
206
+ await scan('/usr/lib/n/versions/node')
207
+ if (IS_WIN && process.env.NVM_HOME) await scan(process.env.NVM_HOME)
208
+ const binName = IS_WIN ? 'node.exe' : 'node'
209
+ const out = []
210
+ for (const p of found) {
211
+ // 支持 <dir>/bin/node、<dir>/node.exe(nvm-windows)、<dir>/installation/bin/node(fnm)
212
+ for (const cand of [p, path.join(p, 'installation')]) {
213
+ if (await fileExists(path.join(cand, 'bin', binName)) || await fileExists(path.join(cand, binName))) {
214
+ const label = path.basename(cand).replace(/^v/, '')
215
+ out.push({ label, path: cand })
216
+ break
217
+ }
218
+ }
219
+ }
220
+ // 版本号倒序,新者在前
221
+ out.sort((a, b) => b.label.localeCompare(a.label, undefined, { numeric: true }))
222
+ return out
223
+ }
224
+
163
225
  // 目录浏览:非法/不可读抛出错误,由 RPC 层转成 ok:false
164
226
  export async function browseDirectory(args = {}) {
165
227
  let p = typeof args.path === 'string' ? args.path : ''
166
228
  const showHidden = args.hidden === true || args.hidden === '1' || args.hidden === 1
229
+ const pickFile = args.file === true || args.file === '1' || args.file === 1
230
+ // 展开 ~/(私钥选择等场景直接从 ~/.ssh 起步)
231
+ if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) {
232
+ p = path.join(process.env.HOME || process.env.USERPROFILE || '/', p.slice(2))
233
+ }
167
234
  if (!p) p = process.env.HOME || (IS_WIN ? (process.env.USERPROFILE || 'C:\\') : '/')
168
235
  if (!path.isAbsolute(p)) throw new Error('必须为绝对路径')
169
236
  p = path.resolve(p)
@@ -173,7 +240,10 @@ export async function browseDirectory(args = {}) {
173
240
  const dirs = entries.filter(d => d.isDirectory() && (showHidden || !d.name.startsWith('.'))).map(d => d.name).sort().slice(0, 200)
174
241
  // 若当前目录属于 git 仓库(含子目录/任意 worktree),列出全部 worktree 供前端切换
175
242
  const worktrees = await listWorktrees(p)
176
- return { path: p, parent, dirs, worktrees }
243
+ const out = { path: p, parent, dirs, worktrees }
244
+ // 文件选择模式(如私钥):额外列出普通文件
245
+ if (pickFile) out.files = entries.filter(d => d.isFile() && (showHidden || !d.name.startsWith('.'))).map(d => d.name).sort().slice(0, 200)
246
+ return out
177
247
  } catch {
178
248
  throw new Error('目录不可读')
179
249
  }
package/server/rpc.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { promises as fsp } from 'node:fs'
2
2
  import { getEnvs, saveEnvs, normalizeEnv, isNewSchema, templateEnv, validateEnv, frontEnvFile, getDefaultProjectPath } from './config.js'
3
3
  import { getHistory, replaceHistory, HIST_LIMIT } from './history.js'
4
- import { activeRecord } from './deployment.js'
5
- import { listJdks, listWorktrees, readGitInfo, browseDirectory, which } from './local-tools.js'
4
+ import { activeRecord, testSsh } from './deployment.js'
5
+ import { listJdks, listNodes, listPackageManagers, listWorktrees, readGitInfo, browseDirectory, which } from './local-tools.js'
6
6
 
7
7
  // sshpass 无 Windows 版本;警告文案按平台给出可操作的下一步(保存/导入两处共用)
8
8
  const SSHPASS_HINT = process.platform === 'win32'
@@ -31,7 +31,7 @@ const methods = {
31
31
  if (i >= 0) envs[i] = env; else envs.push(env)
32
32
  await saveEnvs()
33
33
  let warning = ''
34
- if (env.ssh.password && !(await which('sshpass'))) {
34
+ if ((env.sshs || []).some(s => s.password) && !(await which('sshpass'))) {
35
35
  warning = '已保存,' + SSHPASS_HINT
36
36
  }
37
37
  return { envs: getEnvs(), warning }
@@ -72,7 +72,7 @@ const methods = {
72
72
  await saveEnvs(order.map(id => byId.get(id)))
73
73
  const touched = new Set([...added, ...overwritten])
74
74
  let warning = ''
75
- if (getEnvs().some(e => touched.has(e.id) && e.ssh.password) && !(await which('sshpass'))) {
75
+ if (getEnvs().some(e => touched.has(e.id) && (e.sshs || []).some(s => s.password)) && !(await which('sshpass'))) {
76
76
  warning = '已导入,' + SSHPASS_HINT
77
77
  }
78
78
  return { envs: getEnvs(), added, overwritten, warning }
@@ -89,6 +89,13 @@ const methods = {
89
89
  try { return { text: await fsp.readFile(p, 'utf8') } } catch { return { text: '' } }
90
90
  },
91
91
  jdks: () => listJdks(),
92
+ nodes: () => listNodes(),
93
+ packageManagers: () => listPackageManagers(),
94
+ // 测试 SSH 连通性:target 直接用表单当前值(无需先保存环境)
95
+ sshTest: async ({ target } = {}) => {
96
+ if (!target || typeof target !== 'object') throw new Error('缺少测试目标')
97
+ return testSsh(target)
98
+ },
92
99
  browse: (args) => browseDirectory(args ?? {}),
93
100
  git: async ({ env: id, path: sel } = {}) => {
94
101
  const e = getEnvs().find(x => x.id === id)