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/README.md +2 -2
- package/client.js +346 -117
- package/package.json +2 -2
- package/server/config.js +87 -28
- package/server/deployment.js +110 -36
- package/server/local-tools.js +71 -1
- package/server/rpc.js +11 -4
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-app-deploy",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DSH
|
|
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,
|
|
17
|
-
{ id: 'bsServer', name: 'bs 服务端', kind: 'server', enabled: true,
|
|
18
|
-
{ id: 'adminFront', name: 'admin 前端', kind: 'front', enabled: true,
|
|
19
|
-
{ id: 'adminServer', name: 'admin 服务端', kind: 'server', enabled: true,
|
|
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: '',
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
+
sshs: toSshArray(e),
|
|
79
118
|
modules: toModArray(e.modules, { bs: e.frontEnv?.bs, admin: e.frontEnv?.admin }),
|
|
80
119
|
}
|
|
81
|
-
// 迁移(幂等):把 server
|
|
120
|
+
// 迁移(幂等):把 server 模块构建命令里的 -PexpTime=xxx 提取到 env.expTime 并移除
|
|
82
121
|
let extracted = ''
|
|
83
122
|
for (const m of env.modules) {
|
|
84
|
-
if (m.kind !== 'server' || !m.
|
|
85
|
-
const kept = m.
|
|
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.
|
|
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
|
|
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
|
-
|
|
160
|
-
if (m.id === '
|
|
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
|
-
|
|
175
|
-
if (
|
|
176
|
-
if (
|
|
177
|
-
if (
|
|
178
|
-
if (
|
|
179
|
-
if (
|
|
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 (
|
|
201
|
-
if (m.
|
|
202
|
-
if (
|
|
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}: 远程目标路径必填`)
|
package/server/deployment.js
CHANGED
|
@@ -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(
|
|
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',
|
|
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
|
-
|
|
188
|
-
const
|
|
189
|
-
if (
|
|
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
|
-
|
|
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 =
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
if (m.kind === 'server' && env.expTime) buildArgs.push(`-PexpTime=${env.expTime}`)
|
|
205
|
-
const jdkNote = env.jdkHome ?
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
|
|
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
|
-
|
|
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:
|
|
293
|
+
const rsyncEnv = sshpass ? { ...process.env, SSHPASS: target.password } : process.env
|
|
228
294
|
let c2
|
|
229
295
|
if (rclone) {
|
|
230
|
-
const args = buildRcloneArgs(
|
|
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(
|
|
245
|
-
if (
|
|
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 =
|
|
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
|
-
|
|
280
|
-
const
|
|
281
|
-
// rclone 密码认证走 obscure 混淆值(失败立即中止,避免到同步步骤才报错)
|
|
348
|
+
// rclone 密码认证走 obscure 混淆值(按密码缓存,多目标各自处理;失败立即中止)
|
|
349
|
+
const passCache = new Map()
|
|
282
350
|
let rclone = null
|
|
283
351
|
if (rcloneBin) {
|
|
284
|
-
|
|
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
|
|
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
|
-
|
|
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']
|
package/server/local-tools.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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)
|