dsh-app-deploy 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -2
- package/client.js +1472 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +6 -0
- package/index.js +102 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +40 -4
- package/server/config.js +212 -0
- package/server/deployment.js +392 -0
- package/server/health-check.js +39 -0
- package/server/history.js +40 -0
- package/server/local-tools.js +180 -0
- package/server/rpc.js +129 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { promises as fsp } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { getEnvs } from './config.js'
|
|
5
|
+
import { getHistory, saveHistory, LOGTAIL_CHARS } from './history.js'
|
|
6
|
+
import { listWorktrees, which, rcloneObscure } from './local-tools.js'
|
|
7
|
+
import { checkHealth } from './health-check.js'
|
|
8
|
+
|
|
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'
|
|
13
|
+
let running = null // { clients: Set<ServerResponse>, done, label, record, logTail, lastCode }
|
|
14
|
+
let activeProc = null // 当前正在执行的子进程(gradlew/rsync/ssh),插件卸载时终止
|
|
15
|
+
|
|
16
|
+
function sseWrite(res, chunk) {
|
|
17
|
+
if (!res || res.writableEnded || res.destroyed) return
|
|
18
|
+
try {
|
|
19
|
+
res.write(chunk)
|
|
20
|
+
// dsh Web 宿主对 text/event-stream 也开 gzip,事件会积压在 zlib 缓冲里不刷新;
|
|
21
|
+
// compression 中间件在此响应上挂了 res.flush,写完立刻冲刷(未挂中间件时为 no-op)
|
|
22
|
+
res.flush?.()
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function broadcast(text) {
|
|
27
|
+
if (!running) return
|
|
28
|
+
// 累积尾部日志,写入执行记录(按字符截断到整行,避免频繁分行开销)
|
|
29
|
+
running.logTail += text
|
|
30
|
+
if (running.logTail.length > LOGTAIL_CHARS) {
|
|
31
|
+
const cut = running.logTail.slice(-LOGTAIL_CHARS)
|
|
32
|
+
running.logTail = cut.slice(cut.indexOf('\n') + 1 || 0)
|
|
33
|
+
}
|
|
34
|
+
// 带记录 ID 作为 SSE id:断线重连时浏览器回传 Last-Event-ID,服务端据此加入原部署而非重新开始
|
|
35
|
+
const idLine = running.record ? `id: ${running.record.id}\n` : ''
|
|
36
|
+
for (const c of running.clients) sseWrite(c, `${idLine}data: ${JSON.stringify(text)}\n\n`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// SSE 心跳(注释行,不进日志):防止长静默期连接被中间层断开,浏览器也能感知连接活性
|
|
40
|
+
setInterval(() => {
|
|
41
|
+
if (!running || running.done) return
|
|
42
|
+
for (const c of running.clients) sseWrite(c, ': hb\n\n')
|
|
43
|
+
}, 15000).unref()
|
|
44
|
+
|
|
45
|
+
// 插件卸载/重载时终止正在运行的构建子进程,避免 gradlew 成为孤儿继续跑;
|
|
46
|
+
// gradlew 客户端被杀后,daemon 端会因连接断开取消本次构建
|
|
47
|
+
export function killActiveProcess() {
|
|
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 {}
|
|
53
|
+
activeProc = null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 运行一步子进程并流式广播输出;返回退出码(启动失败为 127)
|
|
57
|
+
function runStep(label, args, opts = {}) {
|
|
58
|
+
broadcast(`>>> ${label}\n`)
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
let settled = false
|
|
61
|
+
const finish = (code) => {
|
|
62
|
+
if (settled) return
|
|
63
|
+
settled = true
|
|
64
|
+
activeProc = null
|
|
65
|
+
resolve(code)
|
|
66
|
+
}
|
|
67
|
+
let child
|
|
68
|
+
try {
|
|
69
|
+
child = spawn(args[0], args.slice(1), { cwd: opts.cwd, env: opts.env, shell: opts.shell, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
70
|
+
activeProc = child
|
|
71
|
+
} catch (err) {
|
|
72
|
+
broadcast(`启动失败: ${String(err)}\n`)
|
|
73
|
+
return resolve(127)
|
|
74
|
+
}
|
|
75
|
+
child.stdout.on('data', (chunk) => broadcast(chunk.toString('utf8')))
|
|
76
|
+
child.stderr.on('data', (chunk) => broadcast(chunk.toString('utf8')))
|
|
77
|
+
child.once('error', (err) => { broadcast(`启动失败: ${String(err)}\n`); finish(127) })
|
|
78
|
+
child.once('close', (code) => finish(code ?? 1))
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 部署结束后尽力停掉 Gradle daemon:daemon 是常驻 JVM(默认空闲 3 小时才退出),
|
|
83
|
+
// 多次构建复用后类加载器/缓存累积会把堆耗尽(报 "Daemon will expire ... running out of JVM heap space")。
|
|
84
|
+
// 无 daemon 时 --stop 也返回 0;任何失败都忽略,不影响部署结果。
|
|
85
|
+
async function stopGradleDaemon(env) {
|
|
86
|
+
try {
|
|
87
|
+
const stopEnv = env.jdkHome ? { ...process.env, JAVA_HOME: env.jdkHome } : process.env
|
|
88
|
+
await new Promise((resolve) => {
|
|
89
|
+
const p = spawn(GRADLEW, ['--stop'], { cwd: env.projectPath, env: stopEnv, shell: IS_WIN, stdio: 'ignore' })
|
|
90
|
+
p.once('error', resolve)
|
|
91
|
+
p.once('close', resolve)
|
|
92
|
+
})
|
|
93
|
+
broadcast('>>> 已停止 Gradle daemon,释放构建内存\n')
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function finishDeploy(code) {
|
|
98
|
+
broadcast(`\n>>> ${code === 0 ? '部署完成' : '部署失败'},退出码 ${code}\n`)
|
|
99
|
+
if (!running) return
|
|
100
|
+
const idLine = running.record ? `id: ${running.record.id}\n` : ''
|
|
101
|
+
for (const c of running.clients) {
|
|
102
|
+
sseWrite(c, `${idLine}event: done\ndata: ${JSON.stringify({ code })}\n\n`)
|
|
103
|
+
try { c.end() } catch {}
|
|
104
|
+
}
|
|
105
|
+
// 落盘执行记录(failedModule/failedStep 由 runDeployment 在调用前写入)
|
|
106
|
+
const r = running.record
|
|
107
|
+
if (r) {
|
|
108
|
+
r.endTime = Date.now()
|
|
109
|
+
r.durationMs = r.endTime - r.startTime
|
|
110
|
+
r.exitCode = code
|
|
111
|
+
r.status = code === 0 ? 'success' : 'failed'
|
|
112
|
+
r.logTail = running.logTail
|
|
113
|
+
saveHistory().catch(() => {})
|
|
114
|
+
}
|
|
115
|
+
running.done = true
|
|
116
|
+
running.lastCode = code // 保留会话供断线重连获取最终结果
|
|
117
|
+
running.clients.clear()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 递归删除(含隐藏文件);lstat 不存在时抛出 ENOENT 由调用方处理
|
|
121
|
+
async function rmTree(p) {
|
|
122
|
+
const st = await fsp.lstat(p)
|
|
123
|
+
if (st.isDirectory()) {
|
|
124
|
+
for (const ent of await fsp.readdir(p)) await rmTree(path.join(p, ent))
|
|
125
|
+
await fsp.rmdir(p)
|
|
126
|
+
} else {
|
|
127
|
+
await fsp.unlink(p)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 推导构建前要清空的目录(相对 projectPath 的绝对路径),不满足安全约束返回 null
|
|
132
|
+
// 前端(kind=front):取 localPath 中 dist 及之前部分(client/client-front/dist/fxy/ → …/dist)
|
|
133
|
+
// 服务端(kind=server):清空 localPath 所在目录(…/build/libs/financial-v5.jar → …/build/libs;目录型 localPath 清空自身)
|
|
134
|
+
export function cleanTarget(env, m) {
|
|
135
|
+
const pp = path.resolve(env.projectPath.replace(/[\\/]+$/, ''))
|
|
136
|
+
const local = path.isAbsolute(m.localPath) ? m.localPath : path.join(pp, m.localPath)
|
|
137
|
+
// 统一成正斜杠视图做推导,兼容 Windows 反斜杠路径
|
|
138
|
+
const lv = local.replace(/\\/g, '/')
|
|
139
|
+
let rel
|
|
140
|
+
if (m.kind === 'front') {
|
|
141
|
+
const i = lv.lastIndexOf('/dist')
|
|
142
|
+
if (i < 0) return null
|
|
143
|
+
rel = lv.slice(0, i + 5)
|
|
144
|
+
} else {
|
|
145
|
+
// 目录型(以 / 或 \ 结尾)清空自身,文件型清空所在目录
|
|
146
|
+
const isDir = /[\\/]$/.test(local)
|
|
147
|
+
const p = lv.replace(/\/+$/, '')
|
|
148
|
+
rel = isDir ? p : p.slice(0, p.lastIndexOf('/'))
|
|
149
|
+
}
|
|
150
|
+
// 安全约束:不含 ..、解析后须在 projectPath 内、深度 ≥ projectPath 下 2 级
|
|
151
|
+
if (!rel || rel.includes('..')) return null
|
|
152
|
+
const resolved = path.resolve(rel.replace(/\/+/g, '/'))
|
|
153
|
+
const relToPp = path.relative(pp, resolved)
|
|
154
|
+
if (relToPp.startsWith('..') || path.isAbsolute(relToPp)) return null
|
|
155
|
+
if (relToPp.split(/[\\/]/).length < 2) return null
|
|
156
|
+
return resolved
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 构建前清理构建目录;返回 0 成功/跳过,非 0 中止部署
|
|
160
|
+
async function cleanBuildDir(env, m) {
|
|
161
|
+
const dir = cleanTarget(env, m)
|
|
162
|
+
if (!dir) {
|
|
163
|
+
broadcast(`[${m.name}] 警告: 无法推导安全的清理目标,跳过清理构建目录\n`)
|
|
164
|
+
return 0
|
|
165
|
+
}
|
|
166
|
+
broadcast(`>>> [${m.name}] 清理构建目录: ${dir}\n`)
|
|
167
|
+
try {
|
|
168
|
+
const st = await fsp.stat(dir)
|
|
169
|
+
if (!st.isDirectory()) { broadcast(`[${m.name}] 警告: 清理目标不是目录,跳过\n`); return 0 }
|
|
170
|
+
for (const ent of await fsp.readdir(dir)) await rmTree(path.join(dir, ent))
|
|
171
|
+
return 0
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (err?.code === 'ENOENT') return 0 // 目录不存在则跳过
|
|
174
|
+
broadcast(`[${m.name}] 清理失败: ${String(err)},中止部署\n`)
|
|
175
|
+
return 1
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 组装 rclone 同步参数(SFTP 后端):sync 自带 --delete 用于前端镜像,copy 不删用于服务端;
|
|
180
|
+
// 密码用 obscure 混淆值(只进 spawn 参数,不进日志标签);密钥取 SSH 自定义选项里的 -i
|
|
181
|
+
export function buildRcloneArgs(env, m, local, rclone) {
|
|
182
|
+
const isFront = m.kind === 'front'
|
|
183
|
+
const args = [isFront ? 'sync' : 'copy', local, `:sftp:${m.remotePath}`,
|
|
184
|
+
'--sftp-host', env.ssh.host, '--sftp-port', String(env.ssh.port), '--sftp-user', env.ssh.user,
|
|
185
|
+
'--stats-one-line', '--stats', '10s', '-v']
|
|
186
|
+
if (isFront) args.push('--exclude', '.user.ini') // 宝塔锁定文件;exclude 同时豁免 sync 的删除
|
|
187
|
+
if (rclone.pass) args.push('--sftp-pass', rclone.pass)
|
|
188
|
+
const key = (env.ssh.options || '').match(/(?:^|\s)-i\s+(\S+)/)
|
|
189
|
+
if (!rclone.pass && key) args.push('--sftp-key-file', key[1])
|
|
190
|
+
return args
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 返回退出码与失败阶段(清理/构建/同步/远程命令),用于执行记录
|
|
194
|
+
// rclone 非空时用 rclone(SFTP)替代 rsync 同步;rsyncEnv 同时承载 sshpass 的 SSHPASS
|
|
195
|
+
export async function deployModule(env, m, sshBase, dest, sshpass, rclone = null) {
|
|
196
|
+
// 0. 清理构建目录(恢复原 update-*.sh 行为)
|
|
197
|
+
const c0 = await cleanBuildDir(env, m)
|
|
198
|
+
if (c0 !== 0) return { code: c0, step: '清理' }
|
|
199
|
+
|
|
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')) {
|
|
210
|
+
const t = line.trim()
|
|
211
|
+
if (!t || t.startsWith('#')) continue
|
|
212
|
+
const eq = t.indexOf('=')
|
|
213
|
+
if (eq <= 0) continue
|
|
214
|
+
const k = t.slice(0, eq).trim(), v = t.slice(eq + 1).trim()
|
|
215
|
+
if (/^[A-Za-z0-9_]+$/.test(k)) envForGradle[k] = v
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const c1 = await runStep(`[${m.name}] 构建: ${buildArgs.join(' ')} ${jdkNote}`, buildArgs, { cwd: env.projectPath, env: envForGradle, shell: IS_WIN })
|
|
219
|
+
if (c1 !== 0) { broadcast(`[${m.name}] 构建失败,退出码 ${c1},中止部署\n`); return { code: c1, step: '构建' } }
|
|
220
|
+
|
|
221
|
+
// 2. 同步:优先 rsync;本机没有时回退 rclone(--delete 仅用于前端模块,与原脚本行为一致)
|
|
222
|
+
const isFront = m.kind === 'front'
|
|
223
|
+
// 保留 rsync 的目录尾斜杠语义:dist/ 传内容本身,dist 传 dist 目录
|
|
224
|
+
const isDirLike = /[\\/]$/.test(m.localPath)
|
|
225
|
+
const joined = path.isAbsolute(m.localPath) ? m.localPath : path.join(env.projectPath, m.localPath)
|
|
226
|
+
const local = joined.replace(/[\\/]+$/, '') + (isDirLike ? '/' : '')
|
|
227
|
+
const rsyncEnv = sshpass ? { ...process.env, SSHPASS: env.ssh.password } : process.env
|
|
228
|
+
let c2
|
|
229
|
+
if (rclone) {
|
|
230
|
+
const args = buildRcloneArgs(env, m, local, rclone)
|
|
231
|
+
c2 = await runStep(`[${m.name}] rclone ${isFront ? 'sync' : 'copy'} ${local} → :sftp:${m.remotePath}${isFront ? '(镜像 --delete)' : ''}`, [rclone.bin, ...args], {})
|
|
232
|
+
} else {
|
|
233
|
+
const rsyncArgs = ['rsync', '-r', '-t', '-z', '-e', sshpass ? `sshpass -e ${sshBase}` : sshBase]
|
|
234
|
+
if (isFront) rsyncArgs.push('--delete', '--exclude=.user.ini') // 排除宝塔 .user.ini(chattr +i 锁定,--delete 无法删除且会导致退出码非 0)
|
|
235
|
+
rsyncArgs.push(local, `${dest}:${m.remotePath}`)
|
|
236
|
+
c2 = await runStep(`[${m.name}] rsync ${local} → ${dest}:${m.remotePath}${isFront ? '(--delete)' : ''}`, rsyncArgs, { env: rsyncEnv })
|
|
237
|
+
}
|
|
238
|
+
if (c2 !== 0) { broadcast(`[${m.name}] 同步失败,退出码 ${c2},中止部署\n`); return { code: c2, step: '同步' } }
|
|
239
|
+
|
|
240
|
+
// 3. ssh 远程命令
|
|
241
|
+
if (m.postCommand.trim()) {
|
|
242
|
+
const sshArgs = []
|
|
243
|
+
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))
|
|
246
|
+
sshArgs.push(dest, m.postCommand.trim())
|
|
247
|
+
const c3 = await runStep(`[${m.name}] 远程执行: ${m.postCommand.trim()}`, sshArgs, { env: rsyncEnv })
|
|
248
|
+
if (c3 !== 0) { broadcast(`[${m.name}] 远程命令失败,退出码 ${c3},中止部署\n`); return { code: c3, step: '远程命令' } }
|
|
249
|
+
}
|
|
250
|
+
if (!await checkHealth(m.healthCheckUrl || '', message => broadcast(`[${m.name}] ${message}`))) {
|
|
251
|
+
return { code: 1, step: '健康检查' }
|
|
252
|
+
}
|
|
253
|
+
return { code: 0, step: null }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function runDeployment(env, mods) {
|
|
257
|
+
let code = 1
|
|
258
|
+
try {
|
|
259
|
+
// 预检本地工具,缺失时给出可操作的提示(避免到同步步骤才报 127 启动失败)
|
|
260
|
+
// 同步优先 rsync,缺失回退 rclone(SFTP);ssh 仅 rsync 传输或有远程命令时需要
|
|
261
|
+
const hasRsync = await which('rsync')
|
|
262
|
+
const rcloneBin = hasRsync ? null : await which('rclone')
|
|
263
|
+
const needSsh = mods.some(m => m.enabled && m.postCommand.trim())
|
|
264
|
+
const missing = []
|
|
265
|
+
if (!hasRsync && !rcloneBin) missing.push('rsync(或 rclone)')
|
|
266
|
+
if ((hasRsync || needSsh) && !(await which('ssh'))) missing.push('ssh')
|
|
267
|
+
// sshpass:rsync 密码传输恒需要;rclone 模式自身支持密码,仅「密码 + 远程命令」时才需要
|
|
268
|
+
const needSshpass = !!env.ssh.password && (hasRsync || needSsh)
|
|
269
|
+
const sshpass = needSshpass ? await which('sshpass') : null
|
|
270
|
+
if (missing.length) {
|
|
271
|
+
broadcast(`错误: 本机未找到 ${missing.join('、')}(须在 PATH 中)。${IS_WIN ? 'Windows 可安装 rclone 替代 rsync(https://rclone.org),ssh 用系统自带 OpenSSH' : '请先安装 rsync(或 rclone)与 OpenSSH'}\n`)
|
|
272
|
+
code = 127
|
|
273
|
+
} else if (needSshpass && !sshpass) {
|
|
274
|
+
broadcast(IS_WIN
|
|
275
|
+
? '错误: 远程命令 + SSH 密码需要 sshpass,而 Windows 无此工具;请改用密钥认证(SSH 自定义选项填 -i 私钥路径,并清空密码),或移除远程命令让 rclone 直接同步\n'
|
|
276
|
+
: '错误: 配置了 SSH 密码但本机没有 sshpass。请先安装: brew install hudochenkov/sshpass/sshpass\n')
|
|
277
|
+
code = 127
|
|
278
|
+
} 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 混淆值(失败立即中止,避免到同步步骤才报错)
|
|
282
|
+
let rclone = null
|
|
283
|
+
if (rcloneBin) {
|
|
284
|
+
const pass = env.ssh.password ? await rcloneObscure(rcloneBin, env.ssh.password) : null
|
|
285
|
+
if (env.ssh.password && !pass) {
|
|
286
|
+
broadcast('错误: rclone obscure 密码处理失败,无法使用密码认证\n')
|
|
287
|
+
code = 127
|
|
288
|
+
return finishDeploy(code) // 尚未构建,无需停 daemon
|
|
289
|
+
}
|
|
290
|
+
rclone = { bin: rcloneBin, pass }
|
|
291
|
+
}
|
|
292
|
+
code = 0
|
|
293
|
+
for (const m of mods) {
|
|
294
|
+
if (!m.enabled) { broadcast(`[跳过] ${m.name} 已禁用\n`); continue }
|
|
295
|
+
const res = await deployModule(env, m, sshBase, dest, sshpass, rclone)
|
|
296
|
+
if (res.code !== 0) {
|
|
297
|
+
if (running?.record) { running.record.failedModule = m.name; running.record.failedStep = res.step }
|
|
298
|
+
code = res.code
|
|
299
|
+
break
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} catch (err) {
|
|
304
|
+
broadcast(`部署异常: ${String(err)}\n`)
|
|
305
|
+
code = 1
|
|
306
|
+
}
|
|
307
|
+
await stopGradleDaemon(env) // 无论成败都停掉 daemon,避免堆累积
|
|
308
|
+
finishDeploy(code)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function sendJson(res, status, payload) {
|
|
312
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
313
|
+
res.end(JSON.stringify(payload))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function handleDeploy(req, res, url) {
|
|
317
|
+
const bad = (error) => sendJson(res, 400, { error })
|
|
318
|
+
const env = getEnvs().find(e => e.id === url.searchParams.get('env'))
|
|
319
|
+
if (!env) return bad('未知环境')
|
|
320
|
+
// 可选 path:前端在 git 信息条临时选择的构建 worktree(不写回配置);必须等于项目路径或属于该仓库 worktree 列表
|
|
321
|
+
let runEnv = env
|
|
322
|
+
const selPath = url.searchParams.get('path') || ''
|
|
323
|
+
if (selPath) {
|
|
324
|
+
const wts = await listWorktrees(env.projectPath)
|
|
325
|
+
if (!wts.some(w => w.path === selPath)) return bad('所选构建路径不属于该仓库的 worktree')
|
|
326
|
+
if (selPath !== env.projectPath.replace(/\/+$/, '')) runEnv = { ...env, projectPath: selPath }
|
|
327
|
+
}
|
|
328
|
+
const orderIds = (url.searchParams.get('modules') || '').split(',').filter(Boolean)
|
|
329
|
+
const want = new Set(orderIds)
|
|
330
|
+
if (!want.size) return bad('未指定模块')
|
|
331
|
+
// 按请求中 modules 参数给出的顺序筛选(前端已按用户拖动后的执行顺序提交),保证按照界面编排的顺序执行
|
|
332
|
+
const ordered = env.modules.filter(m => m.enabled && want.has(m.id))
|
|
333
|
+
const mods = orderIds.map(id => ordered.find(m => m.id === id)).filter(m => !!m)
|
|
334
|
+
if (!mods.length) return bad('所选模块在环境中均不可用或已禁用')
|
|
335
|
+
if (!env.ssh.host || !env.ssh.user) return bad('SSH 配置不完整')
|
|
336
|
+
|
|
337
|
+
const sseHeaders = { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }
|
|
338
|
+
const lastEventId = req.headers['last-event-id']
|
|
339
|
+
const drop = () => { if (running) running.clients.delete(res) }
|
|
340
|
+
const startSse = () => {
|
|
341
|
+
res.writeHead(200, sseHeaders)
|
|
342
|
+
res.flushHeaders()
|
|
343
|
+
req.on('close', drop)
|
|
344
|
+
req.on('error', drop)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// EventSource 断线自动重连(带 Last-Event-ID=部署记录 ID):只加入对应部署,绝不重复触发新部署
|
|
348
|
+
if (lastEventId) {
|
|
349
|
+
if (running && running.record && running.record.id === lastEventId) {
|
|
350
|
+
startSse()
|
|
351
|
+
sseWrite(res, `event: replay\ndata: ${JSON.stringify(running.logTail)}\n\n`)
|
|
352
|
+
if (running.done) {
|
|
353
|
+
// 部署已结束:回放日志后直接发最终结果
|
|
354
|
+
sseWrite(res, `id: ${lastEventId}\nevent: done\ndata: ${JSON.stringify({ code: running.lastCode })}\n\n`)
|
|
355
|
+
res.end()
|
|
356
|
+
} else {
|
|
357
|
+
running.clients.add(res)
|
|
358
|
+
broadcast(`(重连加入部署: ${running.label})\n`)
|
|
359
|
+
}
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
// 对应部署已不存在(如服务已重启):204 告知浏览器停止重连
|
|
363
|
+
res.writeHead(204)
|
|
364
|
+
res.end()
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!running || running.done) {
|
|
369
|
+
startSse()
|
|
370
|
+
const rec = {
|
|
371
|
+
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
|
372
|
+
envId: env.id, envName: env.name,
|
|
373
|
+
modules: mods.map(m => ({ id: m.id, name: m.name })),
|
|
374
|
+
startTime: Date.now(), endTime: null, durationMs: null,
|
|
375
|
+
status: 'running', exitCode: null, failedModule: null, failedStep: null, logTail: '',
|
|
376
|
+
}
|
|
377
|
+
getHistory().push(rec)
|
|
378
|
+
saveHistory().catch(() => {})
|
|
379
|
+
running = { clients: new Set([res]), done: false, label: `${env.name} / ${mods.map(m => m.name).join('+')}`, record: rec, logTail: '', lastCode: null }
|
|
380
|
+
broadcast(`>>> 开始部署 [${env.name} / ${mods.map(m => m.name).join('+')}]\n`)
|
|
381
|
+
if (runEnv !== env) broadcast(`>>> 使用临时构建路径(worktree): ${runEnv.projectPath}\n`)
|
|
382
|
+
runDeployment(runEnv, mods) // 异步执行,不阻塞
|
|
383
|
+
} else {
|
|
384
|
+
// 新浏览器标签页加入:先回放已有日志,再接收后续输出
|
|
385
|
+
startSse()
|
|
386
|
+
sseWrite(res, `event: replay\ndata: ${JSON.stringify(running.logTail)}\n\n`)
|
|
387
|
+
running.clients.add(res)
|
|
388
|
+
broadcast(`(已加入正在进行的部署: ${running.label})\n`)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function activeRecord() { return running && !running.done ? running.record : null }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// 健康检查:HTTP 2xx + success === true + code === 200,单次 5s,间隔 3s,总 120s
|
|
2
|
+
export function isHealthyPayload(body) {
|
|
3
|
+
return !!body && body.success === true && body.code === 200
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export async function checkHealth(url, log, timeoutMs = 120000, intervalMs = 3000) {
|
|
7
|
+
if (!url) return true
|
|
8
|
+
const deadline = Date.now() + timeoutMs
|
|
9
|
+
let attempt = 0
|
|
10
|
+
log('健康检查开始(HTTP 2xx、success=true、code=200)\n')
|
|
11
|
+
while (Date.now() < deadline) {
|
|
12
|
+
let reason
|
|
13
|
+
try {
|
|
14
|
+
const response = await fetch(url, {
|
|
15
|
+
signal: AbortSignal.timeout(Math.max(1, Math.min(5000, deadline - Date.now()))),
|
|
16
|
+
headers: { Accept: 'application/json', 'Cache-Control': 'no-cache' },
|
|
17
|
+
})
|
|
18
|
+
if (response.ok) {
|
|
19
|
+
const body = await response.json()
|
|
20
|
+
if (isHealthyPayload(body)) {
|
|
21
|
+
log('健康检查通过\n')
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
reason = '业务状态不健康(需要 success=true、code=200)'
|
|
25
|
+
} else {
|
|
26
|
+
await response.body?.cancel()
|
|
27
|
+
reason = `HTTP ${response.status}`
|
|
28
|
+
}
|
|
29
|
+
} catch (error) {
|
|
30
|
+
reason = error instanceof Error ? error.name : '请求失败'
|
|
31
|
+
}
|
|
32
|
+
log(`健康检查第 ${++attempt} 次未通过:${reason}\n`)
|
|
33
|
+
const remaining = deadline - Date.now()
|
|
34
|
+
if (remaining <= 0) break
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, remaining)))
|
|
36
|
+
}
|
|
37
|
+
log(`健康检查失败:${timeoutMs / 1000} 秒内未恢复健康\n`)
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { promises as fsp } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// ---------- 部署执行记录(history.json,上限 500 条自动裁剪) ----------
|
|
5
|
+
const HIST_LIMIT = 500
|
|
6
|
+
const LOGTAIL_CHARS = 30000 // 尾部日志字符上限(超出后从头部截断到整行)
|
|
7
|
+
|
|
8
|
+
let history = []
|
|
9
|
+
let histFile = ''
|
|
10
|
+
|
|
11
|
+
async function load() {
|
|
12
|
+
try {
|
|
13
|
+
const h = JSON.parse(await fsp.readFile(histFile, 'utf8'))
|
|
14
|
+
if (Array.isArray(h)) history = h
|
|
15
|
+
} catch {}
|
|
16
|
+
// 上次进程异常退出遗留的 running 记录标记为中断
|
|
17
|
+
for (const r of history) {
|
|
18
|
+
if (r.status === 'running') {
|
|
19
|
+
r.status = 'aborted'
|
|
20
|
+
r.endTime = Date.now()
|
|
21
|
+
r.durationMs = r.endTime - r.startTime
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function initHistory(dir) {
|
|
27
|
+
histFile = path.join(dir, 'history.json')
|
|
28
|
+
await load()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function saveHistory() {
|
|
32
|
+
if (history.length > HIST_LIMIT) history = history.slice(-HIST_LIMIT)
|
|
33
|
+
try { await fsp.writeFile(histFile, JSON.stringify(history, null, 2)) } catch {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getHistory() { return history }
|
|
37
|
+
|
|
38
|
+
export async function replaceHistory(records) { history = records; await saveHistory() }
|
|
39
|
+
|
|
40
|
+
export { HIST_LIMIT, LOGTAIL_CHARS }
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { promises as fsp } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { execFile } from 'node:child_process'
|
|
4
|
+
|
|
5
|
+
// 命令执行:不抛异常,统一返回退出码与输出(Bun.spawn 的等价收敛)
|
|
6
|
+
function run(cmd, args, options = {}) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
try {
|
|
9
|
+
execFile(cmd, args, { maxBuffer: 4 * 1024 * 1024, ...options }, (error, stdout, stderr) => {
|
|
10
|
+
const code = error ? (typeof error.code === 'number' ? error.code : 1) : 0
|
|
11
|
+
resolve({ code, stdout: stdout || '', stderr: stderr || '' })
|
|
12
|
+
})
|
|
13
|
+
} catch {
|
|
14
|
+
resolve({ code: 1, stdout: '', stderr: '' })
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const IS_WIN = process.platform === 'win32'
|
|
20
|
+
|
|
21
|
+
// 等价旧 Bun.which:找到返回绝对路径,找不到返回 null(Windows 用 where)
|
|
22
|
+
export async function which(command) {
|
|
23
|
+
const { code, stdout } = await run(IS_WIN ? 'where' : 'which', [command])
|
|
24
|
+
const found = stdout.split('\n')[0].trim()
|
|
25
|
+
return code === 0 && found ? found : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// rclone obscure 密码混淆(--sftp-pass 只接受混淆值);失败返回 null
|
|
29
|
+
export async function rcloneObscure(bin, password) {
|
|
30
|
+
const { code, stdout } = await run(bin, ['obscure', password])
|
|
31
|
+
const v = stdout.trim()
|
|
32
|
+
return code === 0 && v ? v : null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 列出目录所在 git 仓库的全部 worktree(含主工作区);非 git 仓库/失败返回空数组
|
|
36
|
+
export async function listWorktrees(dir) {
|
|
37
|
+
const worktrees = []
|
|
38
|
+
try {
|
|
39
|
+
const { code, stdout } = await run('git', ['-C', dir, 'worktree', 'list', '--porcelain'])
|
|
40
|
+
if (code === 0) {
|
|
41
|
+
for (const block of stdout.split(/\n\n+/)) {
|
|
42
|
+
const wm = block.match(/^worktree (.+)$/m)
|
|
43
|
+
if (!wm) continue
|
|
44
|
+
const head = (block.match(/^HEAD (.+)$/m) || [])[1] || ''
|
|
45
|
+
const branch = (block.match(/^branch refs\/heads\/(.+)$/m) || [])[1] || ''
|
|
46
|
+
worktrees.push({ path: wm[1], branch, head: head.slice(0, 7) })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
return worktrees
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 读取指定本地目录的 git 分支/提交信息;非 git 仓库或读取失败返回对应的标记字段
|
|
54
|
+
export async function readGitInfo(dir) {
|
|
55
|
+
try {
|
|
56
|
+
// 用 git -C 判断是否为 git 仓库;rev-parse --is-inside-work-tree 定位仓库根
|
|
57
|
+
const rootProc = await run('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree'])
|
|
58
|
+
const rootOut = (rootProc.stdout + rootProc.stderr).trim()
|
|
59
|
+
if (rootProc.code !== 0 || rootOut !== 'true') return { ok: true, isGit: false }
|
|
60
|
+
|
|
61
|
+
// 当前分支(HEAD detached 时 rev-parse --abbrev-ref 会输出 HEAD)
|
|
62
|
+
const branchProc = await run('git', ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'])
|
|
63
|
+
const branch = branchProc.stdout.trim()
|
|
64
|
+
const detached = branch === 'HEAD'
|
|
65
|
+
|
|
66
|
+
// 当前提交短 hash
|
|
67
|
+
const commitProc = await run('git', ['-C', dir, 'rev-parse', '--short=7', 'HEAD'])
|
|
68
|
+
const shortCommit = commitProc.stdout.trim()
|
|
69
|
+
|
|
70
|
+
return { ok: true, isGit: true, branch, shortCommit, detached }
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return { ok: false, error: String(err) }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function fileExists(p) {
|
|
77
|
+
try { await fsp.access(p); return true } catch { return false }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function listJdks() {
|
|
81
|
+
const found = new Set()
|
|
82
|
+
// a) /usr/libexec/java_home -V 输出的已装 JDK 路径(2>&1)
|
|
83
|
+
try {
|
|
84
|
+
const p = await run('/usr/libexec/java_home', ['-V'])
|
|
85
|
+
for (const m of (p.stdout + p.stderr).matchAll(/[ \t](\/(?:[^\s]+))[ \t]*$/gm)) found.add(m[1].replace(/\/$/, ''))
|
|
86
|
+
} catch {}
|
|
87
|
+
// b) /Library/Java/JavaVirtualMachines/*/Contents/Home
|
|
88
|
+
try { for (const n of await fsp.readdir('/Library/Java/JavaVirtualMachines')) found.add(path.join('/Library/Java/JavaVirtualMachines', n, 'Contents/Home')) } catch {}
|
|
89
|
+
// c) Homebrew: opt 符号链接 + Cellar 实际版本目录
|
|
90
|
+
const optHome = async (base, sub) => {
|
|
91
|
+
try {
|
|
92
|
+
const names = await fsp.readdir(base)
|
|
93
|
+
for (const n of names) {
|
|
94
|
+
if (!n.startsWith('openjdk')) continue
|
|
95
|
+
let cur = path.join(base, n)
|
|
96
|
+
for (const s of sub) cur = path.join(cur, s)
|
|
97
|
+
found.add(cur)
|
|
98
|
+
}
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
await optHome('/opt/homebrew/opt', ['libexec', 'openjdk.jdk', 'Contents', 'Home'])
|
|
102
|
+
try {
|
|
103
|
+
for (const n of await fsp.readdir('/opt/homebrew/Cellar')) {
|
|
104
|
+
if (!n.startsWith('openjdk')) continue
|
|
105
|
+
const vdir = '/opt/homebrew/Cellar/' + n
|
|
106
|
+
const vers = await fsp.readdir(vdir).catch(() => [])
|
|
107
|
+
for (const v of vers) found.add(path.join(vdir, v, 'libexec/openjdk.jdk/Contents/Home'))
|
|
108
|
+
}
|
|
109
|
+
} catch {}
|
|
110
|
+
// Windows:JAVA_HOME + 常见厂商安装目录(Oracle / Temurin / Microsoft / Zulu / Corretto)
|
|
111
|
+
if (IS_WIN) {
|
|
112
|
+
if (process.env.JAVA_HOME) found.add(process.env.JAVA_HOME)
|
|
113
|
+
const roots = [process.env.ProgramFiles, process.env['ProgramFiles(x86)']].filter(Boolean)
|
|
114
|
+
const vendors = ['Java', 'Eclipse Adoptium', 'Eclipse Foundation', 'Microsoft', 'Zulu', 'Amazon Corretto']
|
|
115
|
+
for (const root of roots) {
|
|
116
|
+
for (const vendor of vendors) {
|
|
117
|
+
try { for (const n of await fsp.readdir(path.join(root, vendor))) found.add(path.join(root, vendor, n)) } catch {}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// 只保留 bin/java 真实存在者,生成友好短名
|
|
122
|
+
const jdks = []
|
|
123
|
+
for (const p of found) if (await fileExists(path.join(p, 'bin', IS_WIN ? 'java.exe' : 'java'))) {
|
|
124
|
+
// 标签推导统一用正斜杠视图(Windows 反斜杠路径也能复用同一套正则)
|
|
125
|
+
const pv = p.replace(/\\/g, '/')
|
|
126
|
+
const m = pv.match(/\/JavaVirtualMachines\/([^/]+)\.jdk\/Contents\/Home$/)
|
|
127
|
+
let label
|
|
128
|
+
if (m) label = (m[1].match(/\d+(?:\.\d+)*/) || [])[1] || m[1]
|
|
129
|
+
else if (pv.includes('/opt/homebrew/')) {
|
|
130
|
+
const v = (pv.match(/openjdk@?(\d+)/) || [])[1] || (pv.match(/\/(\d+(?:\.\d+)+)\/libexec/) || [])[1]
|
|
131
|
+
label = v ? 'homebrew-' + v : 'homebrew'
|
|
132
|
+
} else if (/^[a-zA-Z]:\//.test(pv)) {
|
|
133
|
+
const segs = pv.split('/').filter(Boolean)
|
|
134
|
+
label = segs.pop() || pv
|
|
135
|
+
} else label = pv.split('/').filter(Boolean).pop()
|
|
136
|
+
jdks.push({ label, path: p })
|
|
137
|
+
}
|
|
138
|
+
// 短名去重:同名者追加版本/opt-cellar 段区分
|
|
139
|
+
const seen = {}
|
|
140
|
+
for (const j of jdks) seen[j.label] = (seen[j.label] || 0) + 1
|
|
141
|
+
const suffix = (p, label) => {
|
|
142
|
+
const segs = p.split('/').filter(Boolean)
|
|
143
|
+
for (let i = segs.length - 1; i >= 0; i--) {
|
|
144
|
+
if (/\d/.test(segs[i]) && !label.includes(segs[i].replace(/[^\d.]/g, ''))) return segs[i]
|
|
145
|
+
}
|
|
146
|
+
return p.includes('/opt/homebrew/opt/') ? 'opt' : p.includes('/Cellar/') ? 'cellar' : ''
|
|
147
|
+
}
|
|
148
|
+
const taken = {}
|
|
149
|
+
const out = []
|
|
150
|
+
for (const j of jdks) {
|
|
151
|
+
let label = j.label, n = 0
|
|
152
|
+
while (taken[label]) {
|
|
153
|
+
const s = suffix(j.path, label)
|
|
154
|
+
label = n++ === 0 ? `${j.label} (${s})` : `${j.label} (${s}-${n})`
|
|
155
|
+
if (!s) { label = `${j.label} (${j.path})`; break }
|
|
156
|
+
}
|
|
157
|
+
taken[label] = true
|
|
158
|
+
out.push({ label, path: j.path })
|
|
159
|
+
}
|
|
160
|
+
return out.sort((a, b) => a.label.localeCompare(b.label))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 目录浏览:非法/不可读抛出错误,由 RPC 层转成 ok:false
|
|
164
|
+
export async function browseDirectory(args = {}) {
|
|
165
|
+
let p = typeof args.path === 'string' ? args.path : ''
|
|
166
|
+
const showHidden = args.hidden === true || args.hidden === '1' || args.hidden === 1
|
|
167
|
+
if (!p) p = process.env.HOME || (IS_WIN ? (process.env.USERPROFILE || 'C:\\') : '/')
|
|
168
|
+
if (!path.isAbsolute(p)) throw new Error('必须为绝对路径')
|
|
169
|
+
p = path.resolve(p)
|
|
170
|
+
try {
|
|
171
|
+
const entries = await fsp.readdir(p, { withFileTypes: true })
|
|
172
|
+
const parent = path.dirname(p)
|
|
173
|
+
const dirs = entries.filter(d => d.isDirectory() && (showHidden || !d.name.startsWith('.'))).map(d => d.name).sort().slice(0, 200)
|
|
174
|
+
// 若当前目录属于 git 仓库(含子目录/任意 worktree),列出全部 worktree 供前端切换
|
|
175
|
+
const worktrees = await listWorktrees(p)
|
|
176
|
+
return { path: p, parent, dirs, worktrees }
|
|
177
|
+
} catch {
|
|
178
|
+
throw new Error('目录不可读')
|
|
179
|
+
}
|
|
180
|
+
}
|