dsh-app-deploy 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/client.js +352 -121
- package/package.json +3 -2
- package/server/config.js +92 -31
- package/server/deployment.js +179 -46
- package/server/local-tools.js +105 -10
- package/server/rpc.js +18 -6
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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
|
+
"repository": "btboys/dsh-app-deploy",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"icon": "./icon.svg",
|
|
7
8
|
"exports": {
|
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
|
}
|
|
@@ -138,6 +178,8 @@ export function getDefaultProjectPath() { return defaultProjectPath }
|
|
|
138
178
|
|
|
139
179
|
// ---------- 校验 ----------
|
|
140
180
|
const BAD_PATH = /[\s;"'`$&|<>()*?[\]{}!]/ // 禁止空格/引号/; & | ` $ 等
|
|
181
|
+
// 绝对路径:posix 以 / 开头,Windows 为盘符 + \ 或 /(如 C:\jdk、D:/proj)
|
|
182
|
+
const isAbsPath = (p) => /^(\/|[a-zA-Z]:[\\/])/.test(p)
|
|
141
183
|
const BAD_CMD = /["'`;&|<>$()*?[\]{}!]/ // 允许空格,其余同上
|
|
142
184
|
|
|
143
185
|
// 前端 .env 文本:只允许 KEY=VALUE(KEY 为 A-Z0-9_)、空行、# 注释
|
|
@@ -152,11 +194,10 @@ export function validateFrontEnv(s) {
|
|
|
152
194
|
|
|
153
195
|
// 前端 .env 文件路径(仅种子 bsFront/adminFront 有约定路径,供「配置为空时读文件作参考」)
|
|
154
196
|
export function frontEnvFile(e, m) {
|
|
155
|
-
const p = (e.projectPath || defaultProjectPath || '').replace(
|
|
197
|
+
const p = (e.projectPath || defaultProjectPath || '').replace(/[\\/]+$/, '')
|
|
156
198
|
if (!p) return null
|
|
157
|
-
|
|
158
|
-
if (m.id === '
|
|
159
|
-
if (m.id === 'adminFront') return `${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')
|
|
160
201
|
return null
|
|
161
202
|
}
|
|
162
203
|
|
|
@@ -165,16 +206,36 @@ export function validateEnv(e) {
|
|
|
165
206
|
if (!/^[a-z0-9-]+$/.test(e.id)) errs.push('ID 只能是小写字母/数字/连字符')
|
|
166
207
|
if (!e.name.trim()) errs.push('名称必填')
|
|
167
208
|
if (e.group && !/^[\u4e00-\u9fa5A-Za-z0-9 -]{1,20}$/.test(e.group)) errs.push('分组只能含中文/字母/数字/空格/连字符,最长 20 字符')
|
|
168
|
-
if (!e.projectPath
|
|
209
|
+
if (!isAbsPath(e.projectPath)) errs.push('项目路径必须是绝对路径(如 /opt/proj 或 C:\\proj)')
|
|
169
210
|
if (BAD_PATH.test(e.projectPath)) errs.push('项目路径含非法字符(禁止空格/引号/; & | ` $ 等)')
|
|
170
|
-
if (e.jdkHome && !e.jdkHome
|
|
211
|
+
if (e.jdkHome && !isAbsPath(e.jdkHome)) errs.push('JDK 路径必须是绝对路径')
|
|
171
212
|
if (e.jdkHome && BAD_PATH.test(e.jdkHome)) errs.push('JDK 路径含非法字符')
|
|
172
|
-
|
|
173
|
-
if (
|
|
174
|
-
if (
|
|
175
|
-
if (
|
|
176
|
-
if (
|
|
177
|
-
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
|
+
}
|
|
178
239
|
if (e.expTime && !/^\d{4}-\d{2}-\d{2}$/.test(e.expTime)) errs.push('过期时间必须是 YYYY-MM-DD')
|
|
179
240
|
if (!Array.isArray(e.modules) || !e.modules.length) errs.push('至少需要一个模块')
|
|
180
241
|
const seenId = {}
|
|
@@ -195,9 +256,9 @@ export function validateEnv(e) {
|
|
|
195
256
|
} catch { errs.push(`${m.name || m.id}: 健康检查 URL 必须是 HTTP/HTTPS 地址,且不能包含用户名或密码`) }
|
|
196
257
|
}
|
|
197
258
|
if (!m.enabled) continue
|
|
198
|
-
if (
|
|
199
|
-
if (m.
|
|
200
|
-
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}: 构建命令含非法字符(禁止引号/; & | \` $ 等)`)
|
|
201
262
|
if (!m.localPath) errs.push(`${m.name || m.id}: 本地产物路径必填`)
|
|
202
263
|
else if (BAD_PATH.test(m.localPath)) errs.push(`${m.name || m.id}: 本地产物路径含非法字符`)
|
|
203
264
|
if (!m.remotePath) errs.push(`${m.name || m.id}: 远程目标路径必填`)
|
package/server/deployment.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
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
|
-
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`)
|
|
@@ -72,14 +79,58 @@ function runStep(label, args, opts = {}) {
|
|
|
72
79
|
})
|
|
73
80
|
}
|
|
74
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
|
+
|
|
75
124
|
// 部署结束后尽力停掉 Gradle daemon:daemon 是常驻 JVM(默认空闲 3 小时才退出),
|
|
76
125
|
// 多次构建复用后类加载器/缓存累积会把堆耗尽(报 "Daemon will expire ... running out of JVM heap space")。
|
|
77
126
|
// 无 daemon 时 --stop 也返回 0;任何失败都忽略,不影响部署结果。
|
|
78
127
|
async function stopGradleDaemon(env) {
|
|
128
|
+
// 非 gradle 项目没有 wrapper,跳过(也避免误播"已停止")
|
|
129
|
+
if (!existsSync(path.join(env.projectPath, IS_WIN ? 'gradlew.bat' : 'gradlew'))) return
|
|
79
130
|
try {
|
|
80
131
|
const stopEnv = env.jdkHome ? { ...process.env, JAVA_HOME: env.jdkHome } : process.env
|
|
81
132
|
await new Promise((resolve) => {
|
|
82
|
-
const p = spawn(
|
|
133
|
+
const p = spawn(GRADLEW, ['--stop'], { cwd: env.projectPath, env: stopEnv, shell: IS_WIN, stdio: 'ignore' })
|
|
83
134
|
p.once('error', resolve)
|
|
84
135
|
p.once('close', resolve)
|
|
85
136
|
})
|
|
@@ -125,25 +176,27 @@ async function rmTree(p) {
|
|
|
125
176
|
// 前端(kind=front):取 localPath 中 dist 及之前部分(client/client-front/dist/fxy/ → …/dist)
|
|
126
177
|
// 服务端(kind=server):清空 localPath 所在目录(…/build/libs/financial-v5.jar → …/build/libs;目录型 localPath 清空自身)
|
|
127
178
|
export function cleanTarget(env, m) {
|
|
128
|
-
const pp = env.projectPath.replace(
|
|
129
|
-
const local = m.localPath
|
|
179
|
+
const pp = path.resolve(env.projectPath.replace(/[\\/]+$/, ''))
|
|
180
|
+
const local = path.isAbsolute(m.localPath) ? m.localPath : path.join(pp, m.localPath)
|
|
181
|
+
// 统一成正斜杠视图做推导,兼容 Windows 反斜杠路径
|
|
182
|
+
const lv = local.replace(/\\/g, '/')
|
|
130
183
|
let rel
|
|
131
184
|
if (m.kind === 'front') {
|
|
132
|
-
const i =
|
|
185
|
+
const i = lv.lastIndexOf('/dist')
|
|
133
186
|
if (i < 0) return null
|
|
134
|
-
rel =
|
|
187
|
+
rel = lv.slice(0, i + 5)
|
|
135
188
|
} else {
|
|
136
|
-
|
|
137
|
-
const
|
|
189
|
+
// 目录型(以 / 或 \ 结尾)清空自身,文件型清空所在目录
|
|
190
|
+
const isDir = /[\\/]$/.test(local)
|
|
191
|
+
const p = lv.replace(/\/+$/, '')
|
|
138
192
|
rel = isDir ? p : p.slice(0, p.lastIndexOf('/'))
|
|
139
193
|
}
|
|
140
|
-
const clean = rel.replace(/\/+/g, '/').replace(/\/+$/, '')
|
|
141
194
|
// 安全约束:不含 ..、解析后须在 projectPath 内、深度 ≥ projectPath 下 2 级
|
|
142
|
-
if (!
|
|
143
|
-
const resolved =
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
195
|
+
if (!rel || rel.includes('..')) return null
|
|
196
|
+
const resolved = path.resolve(rel.replace(/\/+/g, '/'))
|
|
197
|
+
const relToPp = path.relative(pp, resolved)
|
|
198
|
+
if (relToPp.startsWith('..') || path.isAbsolute(relToPp)) return null
|
|
199
|
+
if (relToPp.split(/[\\/]/).length < 2) return null
|
|
147
200
|
return resolved
|
|
148
201
|
}
|
|
149
202
|
|
|
@@ -167,22 +220,56 @@ async function cleanBuildDir(env, m) {
|
|
|
167
220
|
}
|
|
168
221
|
}
|
|
169
222
|
|
|
223
|
+
// 组装 rclone 同步参数(SFTP 后端):sync 自带 --delete 用于前端镜像,copy 不删用于服务端;
|
|
224
|
+
// 密码用 obscure 混淆值(只进 spawn 参数,不进日志标签);密钥取 SSH 自定义选项里的 -i
|
|
225
|
+
export function buildRcloneArgs(target, m, local, rclone) {
|
|
226
|
+
const isFront = m.kind === 'front'
|
|
227
|
+
const args = [isFront ? 'sync' : 'copy', local, `:sftp:${m.remotePath}`,
|
|
228
|
+
'--sftp-host', target.host, '--sftp-port', String(target.port), '--sftp-user', target.user,
|
|
229
|
+
'--stats-one-line', '--stats', '10s', '-v']
|
|
230
|
+
if (isFront) args.push('--exclude', '.user.ini') // 宝塔锁定文件;exclude 同时豁免 sync 的删除
|
|
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)
|
|
238
|
+
return args
|
|
239
|
+
}
|
|
240
|
+
|
|
170
241
|
// 返回退出码与失败阶段(清理/构建/同步/远程命令),用于执行记录
|
|
171
|
-
|
|
242
|
+
// rclone 非空时用 rclone(SFTP)替代 rsync 同步;rsyncEnv 同时承载 sshpass 的 SSHPASS
|
|
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}`
|
|
172
247
|
// 0. 清理构建目录(恢复原 update-*.sh 行为)
|
|
173
248
|
const c0 = await cleanBuildDir(env, m)
|
|
174
249
|
if (c0 !== 0) return { code: c0, step: '清理' }
|
|
175
250
|
|
|
176
|
-
// 1.
|
|
177
|
-
const buildArgs =
|
|
178
|
-
|
|
179
|
-
if (
|
|
180
|
-
if (m.kind === 'server' && env.expTime) buildArgs.push(`-PexpTime=${env.expTime}`)
|
|
181
|
-
const jdkNote = env.jdkHome ?
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
|
|
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')) {
|
|
186
273
|
const t = line.trim()
|
|
187
274
|
if (!t || t.startsWith('#')) continue
|
|
188
275
|
const eq = t.indexOf('=')
|
|
@@ -191,25 +278,38 @@ export async function deployModule(env, m, sshBase, dest, sshpass) {
|
|
|
191
278
|
if (/^[A-Za-z0-9_]+$/.test(k)) envForGradle[k] = v
|
|
192
279
|
}
|
|
193
280
|
}
|
|
194
|
-
|
|
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 })
|
|
195
285
|
if (c1 !== 0) { broadcast(`[${m.name}] 构建失败,退出码 ${c1},中止部署\n`); return { code: c1, step: '构建' } }
|
|
196
286
|
|
|
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]
|
|
287
|
+
// 2. 同步:优先 rsync;本机没有时回退 rclone(--delete 仅用于前端模块,与原脚本行为一致)
|
|
200
288
|
const isFront = m.kind === 'front'
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
const
|
|
289
|
+
// 保留 rsync 的目录尾斜杠语义:dist/ 传内容本身,dist 传 dist 目录
|
|
290
|
+
const isDirLike = /[\\/]$/.test(m.localPath)
|
|
291
|
+
const joined = path.isAbsolute(m.localPath) ? m.localPath : path.join(env.projectPath, m.localPath)
|
|
292
|
+
const local = joined.replace(/[\\/]+$/, '') + (isDirLike ? '/' : '')
|
|
293
|
+
const rsyncEnv = sshpass ? { ...process.env, SSHPASS: target.password } : process.env
|
|
294
|
+
let c2
|
|
295
|
+
if (rclone) {
|
|
296
|
+
const args = buildRcloneArgs(target, m, local, rclone)
|
|
297
|
+
c2 = await runStep(`[${m.name}] rclone ${isFront ? 'sync' : 'copy'} ${local} → :sftp:${m.remotePath}${isFront ? '(镜像 --delete)' : ''}`, [rclone.bin, ...args], {})
|
|
298
|
+
} else {
|
|
299
|
+
const rsyncArgs = ['rsync', '-r', '-t', '-z', '-e', sshpass ? `sshpass -e ${sshBase}` : sshBase]
|
|
300
|
+
if (isFront) rsyncArgs.push('--delete', '--exclude=.user.ini') // 排除宝塔 .user.ini(chattr +i 锁定,--delete 无法删除且会导致退出码非 0)
|
|
301
|
+
rsyncArgs.push(local, `${dest}:${m.remotePath}`)
|
|
302
|
+
c2 = await runStep(`[${m.name}] rsync ${local} → ${dest}:${m.remotePath}${isFront ? '(--delete)' : ''}`, rsyncArgs, { env: rsyncEnv })
|
|
303
|
+
}
|
|
205
304
|
if (c2 !== 0) { broadcast(`[${m.name}] 同步失败,退出码 ${c2},中止部署\n`); return { code: c2, step: '同步' } }
|
|
206
305
|
|
|
207
306
|
// 3. ssh 远程命令
|
|
208
307
|
if (m.postCommand.trim()) {
|
|
209
308
|
const sshArgs = []
|
|
210
309
|
if (sshpass) sshArgs.push('sshpass', '-e')
|
|
211
|
-
sshArgs.push('ssh', '-p', String(
|
|
212
|
-
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))
|
|
213
313
|
sshArgs.push(dest, m.postCommand.trim())
|
|
214
314
|
const c3 = await runStep(`[${m.name}] 远程执行: ${m.postCommand.trim()}`, sshArgs, { env: rsyncEnv })
|
|
215
315
|
if (c3 !== 0) { broadcast(`[${m.name}] 远程命令失败,退出码 ${c3},中止部署\n`); return { code: c3, step: '远程命令' } }
|
|
@@ -223,17 +323,48 @@ export async function deployModule(env, m, sshBase, dest, sshpass) {
|
|
|
223
323
|
async function runDeployment(env, mods) {
|
|
224
324
|
let code = 1
|
|
225
325
|
try {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
326
|
+
// 预检本地工具,缺失时给出可操作的提示(避免到同步步骤才报 127 启动失败)
|
|
327
|
+
// 同步优先 rsync,缺失回退 rclone(SFTP);ssh 仅 rsync 传输或有远程命令时需要
|
|
328
|
+
const hasRsync = await which('rsync')
|
|
329
|
+
const rcloneBin = hasRsync ? null : await which('rclone')
|
|
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)))]
|
|
333
|
+
const missing = []
|
|
334
|
+
if (!hasRsync && !rcloneBin) missing.push('rsync(或 rclone)')
|
|
335
|
+
if ((hasRsync || needSsh) && !(await which('ssh'))) missing.push('ssh')
|
|
336
|
+
// sshpass:rsync 密码传输恒需要;rclone 模式自身支持密码,仅「密码 + 远程命令」时才需要
|
|
337
|
+
const needSshpass = usedTargets.some(t => t.password) && (hasRsync || needSsh)
|
|
338
|
+
const sshpass = needSshpass ? await which('sshpass') : null
|
|
339
|
+
if (missing.length) {
|
|
340
|
+
broadcast(`错误: 本机未找到 ${missing.join('、')}(须在 PATH 中)。${IS_WIN ? 'Windows 可安装 rclone 替代 rsync(https://rclone.org),ssh 用系统自带 OpenSSH' : '请先安装 rsync(或 rclone)与 OpenSSH'}\n`)
|
|
341
|
+
code = 127
|
|
342
|
+
} else if (needSshpass && !sshpass) {
|
|
343
|
+
broadcast(IS_WIN
|
|
344
|
+
? '错误: 远程命令 + SSH 密码需要 sshpass,而 Windows 无此工具;请改用密钥认证(SSH 自定义选项填 -i 私钥路径,并清空密码),或移除远程命令让 rclone 直接同步\n'
|
|
345
|
+
: '错误: 配置了 SSH 密码但本机没有 sshpass。请先安装: brew install hudochenkov/sshpass/sshpass\n')
|
|
229
346
|
code = 127
|
|
230
347
|
} else {
|
|
231
|
-
|
|
232
|
-
const
|
|
348
|
+
// rclone 密码认证走 obscure 混淆值(按密码缓存,多目标各自处理;失败立即中止)
|
|
349
|
+
const passCache = new Map()
|
|
350
|
+
let rclone = null
|
|
351
|
+
if (rcloneBin) {
|
|
352
|
+
rclone = { bin: rcloneBin, pass: null, cache: passCache }
|
|
353
|
+
}
|
|
233
354
|
code = 0
|
|
234
355
|
for (const m of mods) {
|
|
235
356
|
if (!m.enabled) { broadcast(`[跳过] ${m.name} 已禁用\n`); continue }
|
|
236
|
-
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)
|
|
237
368
|
if (res.code !== 0) {
|
|
238
369
|
if (running?.record) { running.record.failedModule = m.name; running.record.failedStep = res.step }
|
|
239
370
|
code = res.code
|
|
@@ -273,7 +404,9 @@ export async function handleDeploy(req, res, url) {
|
|
|
273
404
|
const ordered = env.modules.filter(m => m.enabled && want.has(m.id))
|
|
274
405
|
const mods = orderIds.map(id => ordered.find(m => m.id === id)).filter(m => !!m)
|
|
275
406
|
if (!mods.length) return bad('所选模块在环境中均不可用或已禁用')
|
|
276
|
-
|
|
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 目标配置不完整')
|
|
277
410
|
|
|
278
411
|
const sseHeaders = { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }
|
|
279
412
|
const lastEventId = req.headers['last-event-id']
|