dsh-app-deploy 0.0.0-stage → 0.1.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 +72 -2
- package/client.js +1470 -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 +39 -4
- package/server/config.js +210 -0
- package/server/deployment.js +333 -0
- package/server/health-check.js +39 -0
- package/server/history.js +40 -0
- package/server/local-tools.js +155 -0
- package/server/rpc.js +124 -0
|
@@ -0,0 +1,333 @@
|
|
|
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 } from './local-tools.js'
|
|
7
|
+
import { checkHealth } from './health-check.js'
|
|
8
|
+
|
|
9
|
+
// ---------- SSE 日志与部署锁(同一时刻只允许一个部署) ----------
|
|
10
|
+
let running = null // { clients: Set<ServerResponse>, done, label, record, logTail, lastCode }
|
|
11
|
+
let activeProc = null // 当前正在执行的子进程(gradlew/rsync/ssh),插件卸载时终止
|
|
12
|
+
|
|
13
|
+
function sseWrite(res, chunk) {
|
|
14
|
+
if (!res || res.writableEnded || res.destroyed) return
|
|
15
|
+
try {
|
|
16
|
+
res.write(chunk)
|
|
17
|
+
// dsh Web 宿主对 text/event-stream 也开 gzip,事件会积压在 zlib 缓冲里不刷新;
|
|
18
|
+
// compression 中间件在此响应上挂了 res.flush,写完立刻冲刷(未挂中间件时为 no-op)
|
|
19
|
+
res.flush?.()
|
|
20
|
+
} catch {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function broadcast(text) {
|
|
24
|
+
if (!running) return
|
|
25
|
+
// 累积尾部日志,写入执行记录(按字符截断到整行,避免频繁分行开销)
|
|
26
|
+
running.logTail += text
|
|
27
|
+
if (running.logTail.length > LOGTAIL_CHARS) {
|
|
28
|
+
const cut = running.logTail.slice(-LOGTAIL_CHARS)
|
|
29
|
+
running.logTail = cut.slice(cut.indexOf('\n') + 1 || 0)
|
|
30
|
+
}
|
|
31
|
+
// 带记录 ID 作为 SSE id:断线重连时浏览器回传 Last-Event-ID,服务端据此加入原部署而非重新开始
|
|
32
|
+
const idLine = running.record ? `id: ${running.record.id}\n` : ''
|
|
33
|
+
for (const c of running.clients) sseWrite(c, `${idLine}data: ${JSON.stringify(text)}\n\n`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// SSE 心跳(注释行,不进日志):防止长静默期连接被中间层断开,浏览器也能感知连接活性
|
|
37
|
+
setInterval(() => {
|
|
38
|
+
if (!running || running.done) return
|
|
39
|
+
for (const c of running.clients) sseWrite(c, ': hb\n\n')
|
|
40
|
+
}, 15000).unref()
|
|
41
|
+
|
|
42
|
+
// 插件卸载/重载时终止正在运行的构建子进程,避免 gradlew 成为孤儿继续跑;
|
|
43
|
+
// gradlew 客户端被杀后,daemon 端会因连接断开取消本次构建
|
|
44
|
+
export function killActiveProcess() {
|
|
45
|
+
try { activeProc?.kill() } catch {}
|
|
46
|
+
activeProc = null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 运行一步子进程并流式广播输出;返回退出码(启动失败为 127)
|
|
50
|
+
function runStep(label, args, opts = {}) {
|
|
51
|
+
broadcast(`>>> ${label}\n`)
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
let settled = false
|
|
54
|
+
const finish = (code) => {
|
|
55
|
+
if (settled) return
|
|
56
|
+
settled = true
|
|
57
|
+
activeProc = null
|
|
58
|
+
resolve(code)
|
|
59
|
+
}
|
|
60
|
+
let child
|
|
61
|
+
try {
|
|
62
|
+
child = spawn(args[0], args.slice(1), { cwd: opts.cwd, env: opts.env, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
63
|
+
activeProc = child
|
|
64
|
+
} catch (err) {
|
|
65
|
+
broadcast(`启动失败: ${String(err)}\n`)
|
|
66
|
+
return resolve(127)
|
|
67
|
+
}
|
|
68
|
+
child.stdout.on('data', (chunk) => broadcast(chunk.toString('utf8')))
|
|
69
|
+
child.stderr.on('data', (chunk) => broadcast(chunk.toString('utf8')))
|
|
70
|
+
child.once('error', (err) => { broadcast(`启动失败: ${String(err)}\n`); finish(127) })
|
|
71
|
+
child.once('close', (code) => finish(code ?? 1))
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 部署结束后尽力停掉 Gradle daemon:daemon 是常驻 JVM(默认空闲 3 小时才退出),
|
|
76
|
+
// 多次构建复用后类加载器/缓存累积会把堆耗尽(报 "Daemon will expire ... running out of JVM heap space")。
|
|
77
|
+
// 无 daemon 时 --stop 也返回 0;任何失败都忽略,不影响部署结果。
|
|
78
|
+
async function stopGradleDaemon(env) {
|
|
79
|
+
try {
|
|
80
|
+
const stopEnv = env.jdkHome ? { ...process.env, JAVA_HOME: env.jdkHome } : process.env
|
|
81
|
+
await new Promise((resolve) => {
|
|
82
|
+
const p = spawn('./gradlew', ['--stop'], { cwd: env.projectPath, env: stopEnv, stdio: 'ignore' })
|
|
83
|
+
p.once('error', resolve)
|
|
84
|
+
p.once('close', resolve)
|
|
85
|
+
})
|
|
86
|
+
broadcast('>>> 已停止 Gradle daemon,释放构建内存\n')
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function finishDeploy(code) {
|
|
91
|
+
broadcast(`\n>>> ${code === 0 ? '部署完成' : '部署失败'},退出码 ${code}\n`)
|
|
92
|
+
if (!running) return
|
|
93
|
+
const idLine = running.record ? `id: ${running.record.id}\n` : ''
|
|
94
|
+
for (const c of running.clients) {
|
|
95
|
+
sseWrite(c, `${idLine}event: done\ndata: ${JSON.stringify({ code })}\n\n`)
|
|
96
|
+
try { c.end() } catch {}
|
|
97
|
+
}
|
|
98
|
+
// 落盘执行记录(failedModule/failedStep 由 runDeployment 在调用前写入)
|
|
99
|
+
const r = running.record
|
|
100
|
+
if (r) {
|
|
101
|
+
r.endTime = Date.now()
|
|
102
|
+
r.durationMs = r.endTime - r.startTime
|
|
103
|
+
r.exitCode = code
|
|
104
|
+
r.status = code === 0 ? 'success' : 'failed'
|
|
105
|
+
r.logTail = running.logTail
|
|
106
|
+
saveHistory().catch(() => {})
|
|
107
|
+
}
|
|
108
|
+
running.done = true
|
|
109
|
+
running.lastCode = code // 保留会话供断线重连获取最终结果
|
|
110
|
+
running.clients.clear()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 递归删除(含隐藏文件);lstat 不存在时抛出 ENOENT 由调用方处理
|
|
114
|
+
async function rmTree(p) {
|
|
115
|
+
const st = await fsp.lstat(p)
|
|
116
|
+
if (st.isDirectory()) {
|
|
117
|
+
for (const ent of await fsp.readdir(p)) await rmTree(path.join(p, ent))
|
|
118
|
+
await fsp.rmdir(p)
|
|
119
|
+
} else {
|
|
120
|
+
await fsp.unlink(p)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 推导构建前要清空的目录(相对 projectPath 的绝对路径),不满足安全约束返回 null
|
|
125
|
+
// 前端(kind=front):取 localPath 中 dist 及之前部分(client/client-front/dist/fxy/ → …/dist)
|
|
126
|
+
// 服务端(kind=server):清空 localPath 所在目录(…/build/libs/financial-v5.jar → …/build/libs;目录型 localPath 清空自身)
|
|
127
|
+
export function cleanTarget(env, m) {
|
|
128
|
+
const pp = env.projectPath.replace(/\/+$/, '')
|
|
129
|
+
const local = m.localPath.startsWith('/') ? m.localPath : `${pp}/${m.localPath}`
|
|
130
|
+
let rel
|
|
131
|
+
if (m.kind === 'front') {
|
|
132
|
+
const i = local.lastIndexOf('/dist')
|
|
133
|
+
if (i < 0) return null
|
|
134
|
+
rel = local.slice(0, i + 5)
|
|
135
|
+
} else {
|
|
136
|
+
const isDir = local.endsWith('/')
|
|
137
|
+
const p = local.replace(/\/+$/, '')
|
|
138
|
+
rel = isDir ? p : p.slice(0, p.lastIndexOf('/'))
|
|
139
|
+
}
|
|
140
|
+
const clean = rel.replace(/\/+/g, '/').replace(/\/+$/, '')
|
|
141
|
+
// 安全约束:不含 ..、解析后须在 projectPath 内、深度 ≥ projectPath 下 2 级
|
|
142
|
+
if (!clean || clean.includes('..')) return null
|
|
143
|
+
const resolved = clean.startsWith('/') ? clean : `${pp}/${clean}`
|
|
144
|
+
if (!resolved.startsWith(pp + '/')) return null
|
|
145
|
+
const depth = resolved.slice(pp.length + 1).split('/').length
|
|
146
|
+
if (depth < 2) return null
|
|
147
|
+
return resolved
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 构建前清理构建目录;返回 0 成功/跳过,非 0 中止部署
|
|
151
|
+
async function cleanBuildDir(env, m) {
|
|
152
|
+
const dir = cleanTarget(env, m)
|
|
153
|
+
if (!dir) {
|
|
154
|
+
broadcast(`[${m.name}] 警告: 无法推导安全的清理目标,跳过清理构建目录\n`)
|
|
155
|
+
return 0
|
|
156
|
+
}
|
|
157
|
+
broadcast(`>>> [${m.name}] 清理构建目录: ${dir}\n`)
|
|
158
|
+
try {
|
|
159
|
+
const st = await fsp.stat(dir)
|
|
160
|
+
if (!st.isDirectory()) { broadcast(`[${m.name}] 警告: 清理目标不是目录,跳过\n`); return 0 }
|
|
161
|
+
for (const ent of await fsp.readdir(dir)) await rmTree(path.join(dir, ent))
|
|
162
|
+
return 0
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (err?.code === 'ENOENT') return 0 // 目录不存在则跳过
|
|
165
|
+
broadcast(`[${m.name}] 清理失败: ${String(err)},中止部署\n`)
|
|
166
|
+
return 1
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 返回退出码与失败阶段(清理/构建/同步/远程命令),用于执行记录
|
|
171
|
+
export async function deployModule(env, m, sshBase, dest, sshpass) {
|
|
172
|
+
// 0. 清理构建目录(恢复原 update-*.sh 行为)
|
|
173
|
+
const c0 = await cleanBuildDir(env, m)
|
|
174
|
+
if (c0 !== 0) return { code: c0, step: '清理' }
|
|
175
|
+
|
|
176
|
+
// 1. 构建
|
|
177
|
+
const buildArgs = ['./gradlew', m.gradleTask]
|
|
178
|
+
if (m.key) buildArgs.push(`-Pkey=${m.key}`)
|
|
179
|
+
if (m.extraArgs) buildArgs.push(...m.extraArgs.trim().split(/\s+/).filter(Boolean))
|
|
180
|
+
if (m.kind === 'server' && env.expTime) buildArgs.push(`-PexpTime=${env.expTime}`)
|
|
181
|
+
const jdkNote = env.jdkHome ? `(JAVA_HOME=${env.jdkHome})` : ''
|
|
182
|
+
const envForGradle = env.jdkHome ? { ...process.env, JAVA_HOME: env.jdkHome } : { ...process.env }
|
|
183
|
+
// 前端模块:把 frontEnv(KEY=VALUE 多行)注入子进程 env;Vite loadEnv 对 process.env 中 VITE_ 前缀变量优先级最高,会覆盖 .env 文件
|
|
184
|
+
if (m.kind === 'front') {
|
|
185
|
+
for (const line of (m.frontEnv || '').split('\n')) {
|
|
186
|
+
const t = line.trim()
|
|
187
|
+
if (!t || t.startsWith('#')) continue
|
|
188
|
+
const eq = t.indexOf('=')
|
|
189
|
+
if (eq <= 0) continue
|
|
190
|
+
const k = t.slice(0, eq).trim(), v = t.slice(eq + 1).trim()
|
|
191
|
+
if (/^[A-Za-z0-9_]+$/.test(k)) envForGradle[k] = v
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const c1 = await runStep(`[${m.name}] 构建: ${buildArgs.join(' ')} ${jdkNote}`, buildArgs, { cwd: env.projectPath, env: envForGradle })
|
|
195
|
+
if (c1 !== 0) { broadcast(`[${m.name}] 构建失败,退出码 ${c1},中止部署\n`); return { code: c1, step: '构建' } }
|
|
196
|
+
|
|
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]
|
|
200
|
+
const isFront = m.kind === 'front'
|
|
201
|
+
if (isFront) rsyncArgs.push('--delete', '--exclude=.user.ini') // 排除宝塔 .user.ini(chattr +i 锁定,--delete 无法删除且会导致退出码非 0)
|
|
202
|
+
rsyncArgs.push(local, `${dest}:${m.remotePath}`)
|
|
203
|
+
const rsyncEnv = sshpass ? { ...process.env, SSHPASS: env.ssh.password } : process.env
|
|
204
|
+
const c2 = await runStep(`[${m.name}] rsync ${local} → ${dest}:${m.remotePath}${isFront ? '(--delete)' : ''}`, rsyncArgs, { env: rsyncEnv })
|
|
205
|
+
if (c2 !== 0) { broadcast(`[${m.name}] 同步失败,退出码 ${c2},中止部署\n`); return { code: c2, step: '同步' } }
|
|
206
|
+
|
|
207
|
+
// 3. ssh 远程命令
|
|
208
|
+
if (m.postCommand.trim()) {
|
|
209
|
+
const sshArgs = []
|
|
210
|
+
if (sshpass) sshArgs.push('sshpass', '-e')
|
|
211
|
+
sshArgs.push('ssh', '-p', String(env.ssh.port))
|
|
212
|
+
if (env.ssh.options) sshArgs.push(...env.ssh.options.trim().split(/\s+/).filter(Boolean))
|
|
213
|
+
sshArgs.push(dest, m.postCommand.trim())
|
|
214
|
+
const c3 = await runStep(`[${m.name}] 远程执行: ${m.postCommand.trim()}`, sshArgs, { env: rsyncEnv })
|
|
215
|
+
if (c3 !== 0) { broadcast(`[${m.name}] 远程命令失败,退出码 ${c3},中止部署\n`); return { code: c3, step: '远程命令' } }
|
|
216
|
+
}
|
|
217
|
+
if (!await checkHealth(m.healthCheckUrl || '', message => broadcast(`[${m.name}] ${message}`))) {
|
|
218
|
+
return { code: 1, step: '健康检查' }
|
|
219
|
+
}
|
|
220
|
+
return { code: 0, step: null }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function runDeployment(env, mods) {
|
|
224
|
+
let code = 1
|
|
225
|
+
try {
|
|
226
|
+
const sshpass = env.ssh.password ? await which('sshpass') : null
|
|
227
|
+
if (env.ssh.password && !sshpass) {
|
|
228
|
+
broadcast('错误: 配置了 SSH 密码但本机没有 sshpass。请先安装: brew install hudochenkov/sshpass/sshpass\n')
|
|
229
|
+
code = 127
|
|
230
|
+
} else {
|
|
231
|
+
const sshBase = `ssh -p ${env.ssh.port}${env.ssh.options ? ' ' + env.ssh.options : ''}`
|
|
232
|
+
const dest = `${env.ssh.user}@${env.ssh.host}`
|
|
233
|
+
code = 0
|
|
234
|
+
for (const m of mods) {
|
|
235
|
+
if (!m.enabled) { broadcast(`[跳过] ${m.name} 已禁用\n`); continue }
|
|
236
|
+
const res = await deployModule(env, m, sshBase, dest, sshpass)
|
|
237
|
+
if (res.code !== 0) {
|
|
238
|
+
if (running?.record) { running.record.failedModule = m.name; running.record.failedStep = res.step }
|
|
239
|
+
code = res.code
|
|
240
|
+
break
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} catch (err) {
|
|
245
|
+
broadcast(`部署异常: ${String(err)}\n`)
|
|
246
|
+
code = 1
|
|
247
|
+
}
|
|
248
|
+
await stopGradleDaemon(env) // 无论成败都停掉 daemon,避免堆累积
|
|
249
|
+
finishDeploy(code)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function sendJson(res, status, payload) {
|
|
253
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
254
|
+
res.end(JSON.stringify(payload))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function handleDeploy(req, res, url) {
|
|
258
|
+
const bad = (error) => sendJson(res, 400, { error })
|
|
259
|
+
const env = getEnvs().find(e => e.id === url.searchParams.get('env'))
|
|
260
|
+
if (!env) return bad('未知环境')
|
|
261
|
+
// 可选 path:前端在 git 信息条临时选择的构建 worktree(不写回配置);必须等于项目路径或属于该仓库 worktree 列表
|
|
262
|
+
let runEnv = env
|
|
263
|
+
const selPath = url.searchParams.get('path') || ''
|
|
264
|
+
if (selPath) {
|
|
265
|
+
const wts = await listWorktrees(env.projectPath)
|
|
266
|
+
if (!wts.some(w => w.path === selPath)) return bad('所选构建路径不属于该仓库的 worktree')
|
|
267
|
+
if (selPath !== env.projectPath.replace(/\/+$/, '')) runEnv = { ...env, projectPath: selPath }
|
|
268
|
+
}
|
|
269
|
+
const orderIds = (url.searchParams.get('modules') || '').split(',').filter(Boolean)
|
|
270
|
+
const want = new Set(orderIds)
|
|
271
|
+
if (!want.size) return bad('未指定模块')
|
|
272
|
+
// 按请求中 modules 参数给出的顺序筛选(前端已按用户拖动后的执行顺序提交),保证按照界面编排的顺序执行
|
|
273
|
+
const ordered = env.modules.filter(m => m.enabled && want.has(m.id))
|
|
274
|
+
const mods = orderIds.map(id => ordered.find(m => m.id === id)).filter(m => !!m)
|
|
275
|
+
if (!mods.length) return bad('所选模块在环境中均不可用或已禁用')
|
|
276
|
+
if (!env.ssh.host || !env.ssh.user) return bad('SSH 配置不完整')
|
|
277
|
+
|
|
278
|
+
const sseHeaders = { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }
|
|
279
|
+
const lastEventId = req.headers['last-event-id']
|
|
280
|
+
const drop = () => { if (running) running.clients.delete(res) }
|
|
281
|
+
const startSse = () => {
|
|
282
|
+
res.writeHead(200, sseHeaders)
|
|
283
|
+
res.flushHeaders()
|
|
284
|
+
req.on('close', drop)
|
|
285
|
+
req.on('error', drop)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// EventSource 断线自动重连(带 Last-Event-ID=部署记录 ID):只加入对应部署,绝不重复触发新部署
|
|
289
|
+
if (lastEventId) {
|
|
290
|
+
if (running && running.record && running.record.id === lastEventId) {
|
|
291
|
+
startSse()
|
|
292
|
+
sseWrite(res, `event: replay\ndata: ${JSON.stringify(running.logTail)}\n\n`)
|
|
293
|
+
if (running.done) {
|
|
294
|
+
// 部署已结束:回放日志后直接发最终结果
|
|
295
|
+
sseWrite(res, `id: ${lastEventId}\nevent: done\ndata: ${JSON.stringify({ code: running.lastCode })}\n\n`)
|
|
296
|
+
res.end()
|
|
297
|
+
} else {
|
|
298
|
+
running.clients.add(res)
|
|
299
|
+
broadcast(`(重连加入部署: ${running.label})\n`)
|
|
300
|
+
}
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
// 对应部署已不存在(如服务已重启):204 告知浏览器停止重连
|
|
304
|
+
res.writeHead(204)
|
|
305
|
+
res.end()
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!running || running.done) {
|
|
310
|
+
startSse()
|
|
311
|
+
const rec = {
|
|
312
|
+
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
|
313
|
+
envId: env.id, envName: env.name,
|
|
314
|
+
modules: mods.map(m => ({ id: m.id, name: m.name })),
|
|
315
|
+
startTime: Date.now(), endTime: null, durationMs: null,
|
|
316
|
+
status: 'running', exitCode: null, failedModule: null, failedStep: null, logTail: '',
|
|
317
|
+
}
|
|
318
|
+
getHistory().push(rec)
|
|
319
|
+
saveHistory().catch(() => {})
|
|
320
|
+
running = { clients: new Set([res]), done: false, label: `${env.name} / ${mods.map(m => m.name).join('+')}`, record: rec, logTail: '', lastCode: null }
|
|
321
|
+
broadcast(`>>> 开始部署 [${env.name} / ${mods.map(m => m.name).join('+')}]\n`)
|
|
322
|
+
if (runEnv !== env) broadcast(`>>> 使用临时构建路径(worktree): ${runEnv.projectPath}\n`)
|
|
323
|
+
runDeployment(runEnv, mods) // 异步执行,不阻塞
|
|
324
|
+
} else {
|
|
325
|
+
// 新浏览器标签页加入:先回放已有日志,再接收后续输出
|
|
326
|
+
startSse()
|
|
327
|
+
sseWrite(res, `event: replay\ndata: ${JSON.stringify(running.logTail)}\n\n`)
|
|
328
|
+
running.clients.add(res)
|
|
329
|
+
broadcast(`(已加入正在进行的部署: ${running.label})\n`)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
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,155 @@
|
|
|
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
|
+
// 等价旧 Bun.which:找到返回绝对路径,找不到返回 null
|
|
20
|
+
export async function which(command) {
|
|
21
|
+
const { code, stdout } = await run('which', [command])
|
|
22
|
+
const found = stdout.trim()
|
|
23
|
+
return code === 0 && found ? found : null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 列出目录所在 git 仓库的全部 worktree(含主工作区);非 git 仓库/失败返回空数组
|
|
27
|
+
export async function listWorktrees(dir) {
|
|
28
|
+
const worktrees = []
|
|
29
|
+
try {
|
|
30
|
+
const { code, stdout } = await run('git', ['-C', dir, 'worktree', 'list', '--porcelain'])
|
|
31
|
+
if (code === 0) {
|
|
32
|
+
for (const block of stdout.split(/\n\n+/)) {
|
|
33
|
+
const wm = block.match(/^worktree (.+)$/m)
|
|
34
|
+
if (!wm) continue
|
|
35
|
+
const head = (block.match(/^HEAD (.+)$/m) || [])[1] || ''
|
|
36
|
+
const branch = (block.match(/^branch refs\/heads\/(.+)$/m) || [])[1] || ''
|
|
37
|
+
worktrees.push({ path: wm[1], branch, head: head.slice(0, 7) })
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} catch {}
|
|
41
|
+
return worktrees
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 读取指定本地目录的 git 分支/提交信息;非 git 仓库或读取失败返回对应的标记字段
|
|
45
|
+
export async function readGitInfo(dir) {
|
|
46
|
+
try {
|
|
47
|
+
// 用 git -C 判断是否为 git 仓库;rev-parse --is-inside-work-tree 定位仓库根
|
|
48
|
+
const rootProc = await run('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree'])
|
|
49
|
+
const rootOut = (rootProc.stdout + rootProc.stderr).trim()
|
|
50
|
+
if (rootProc.code !== 0 || rootOut !== 'true') return { ok: true, isGit: false }
|
|
51
|
+
|
|
52
|
+
// 当前分支(HEAD detached 时 rev-parse --abbrev-ref 会输出 HEAD)
|
|
53
|
+
const branchProc = await run('git', ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'])
|
|
54
|
+
const branch = branchProc.stdout.trim()
|
|
55
|
+
const detached = branch === 'HEAD'
|
|
56
|
+
|
|
57
|
+
// 当前提交短 hash
|
|
58
|
+
const commitProc = await run('git', ['-C', dir, 'rev-parse', '--short=7', 'HEAD'])
|
|
59
|
+
const shortCommit = commitProc.stdout.trim()
|
|
60
|
+
|
|
61
|
+
return { ok: true, isGit: true, branch, shortCommit, detached }
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return { ok: false, error: String(err) }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function fileExists(p) {
|
|
68
|
+
try { await fsp.access(p); return true } catch { return false }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function listJdks() {
|
|
72
|
+
const found = new Set()
|
|
73
|
+
// a) /usr/libexec/java_home -V 输出的已装 JDK 路径(2>&1)
|
|
74
|
+
try {
|
|
75
|
+
const p = await run('/usr/libexec/java_home', ['-V'])
|
|
76
|
+
for (const m of (p.stdout + p.stderr).matchAll(/[ \t](\/(?:[^\s]+))[ \t]*$/gm)) found.add(m[1].replace(/\/$/, ''))
|
|
77
|
+
} catch {}
|
|
78
|
+
// b) /Library/Java/JavaVirtualMachines/*/Contents/Home
|
|
79
|
+
try { for (const n of await fsp.readdir('/Library/Java/JavaVirtualMachines')) found.add(path.join('/Library/Java/JavaVirtualMachines', n, 'Contents/Home')) } catch {}
|
|
80
|
+
// c) Homebrew: opt 符号链接 + Cellar 实际版本目录
|
|
81
|
+
const optHome = async (base, sub) => {
|
|
82
|
+
try {
|
|
83
|
+
const names = await fsp.readdir(base)
|
|
84
|
+
for (const n of names) {
|
|
85
|
+
if (!n.startsWith('openjdk')) continue
|
|
86
|
+
let cur = path.join(base, n)
|
|
87
|
+
for (const s of sub) cur = path.join(cur, s)
|
|
88
|
+
found.add(cur)
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
await optHome('/opt/homebrew/opt', ['libexec', 'openjdk.jdk', 'Contents', 'Home'])
|
|
93
|
+
try {
|
|
94
|
+
for (const n of await fsp.readdir('/opt/homebrew/Cellar')) {
|
|
95
|
+
if (!n.startsWith('openjdk')) continue
|
|
96
|
+
const vdir = '/opt/homebrew/Cellar/' + n
|
|
97
|
+
const vers = await fsp.readdir(vdir).catch(() => [])
|
|
98
|
+
for (const v of vers) found.add(path.join(vdir, v, 'libexec/openjdk.jdk/Contents/Home'))
|
|
99
|
+
}
|
|
100
|
+
} catch {}
|
|
101
|
+
// 只保留 bin/java 真实存在者,生成友好短名
|
|
102
|
+
const jdks = []
|
|
103
|
+
for (const p of found) if (await fileExists(`${p}/bin/java`)) {
|
|
104
|
+
const m = p.match(/\/JavaVirtualMachines\/([^/]+)\.jdk\/Contents\/Home$/)
|
|
105
|
+
let label
|
|
106
|
+
if (m) label = (m[1].match(/\d+(?:\.\d+)*/) || [])[1] || m[1]
|
|
107
|
+
else if (p.includes('/opt/homebrew/')) {
|
|
108
|
+
const v = (p.match(/openjdk@?(\d+)/) || [])[1] || (p.match(/\/(\d+(?:\.\d+)+)\/libexec/) || [])[1]
|
|
109
|
+
label = v ? 'homebrew-' + v : 'homebrew'
|
|
110
|
+
} else label = p.split('/').filter(Boolean).pop()
|
|
111
|
+
jdks.push({ label, path: p })
|
|
112
|
+
}
|
|
113
|
+
// 短名去重:同名者追加版本/opt-cellar 段区分
|
|
114
|
+
const seen = {}
|
|
115
|
+
for (const j of jdks) seen[j.label] = (seen[j.label] || 0) + 1
|
|
116
|
+
const suffix = (p, label) => {
|
|
117
|
+
const segs = p.split('/').filter(Boolean)
|
|
118
|
+
for (let i = segs.length - 1; i >= 0; i--) {
|
|
119
|
+
if (/\d/.test(segs[i]) && !label.includes(segs[i].replace(/[^\d.]/g, ''))) return segs[i]
|
|
120
|
+
}
|
|
121
|
+
return p.includes('/opt/homebrew/opt/') ? 'opt' : p.includes('/Cellar/') ? 'cellar' : ''
|
|
122
|
+
}
|
|
123
|
+
const taken = {}
|
|
124
|
+
const out = []
|
|
125
|
+
for (const j of jdks) {
|
|
126
|
+
let label = j.label, n = 0
|
|
127
|
+
while (taken[label]) {
|
|
128
|
+
const s = suffix(j.path, label)
|
|
129
|
+
label = n++ === 0 ? `${j.label} (${s})` : `${j.label} (${s}-${n})`
|
|
130
|
+
if (!s) { label = `${j.label} (${j.path})`; break }
|
|
131
|
+
}
|
|
132
|
+
taken[label] = true
|
|
133
|
+
out.push({ label, path: j.path })
|
|
134
|
+
}
|
|
135
|
+
return out.sort((a, b) => a.label.localeCompare(b.label))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 目录浏览:非法/不可读抛出错误,由 RPC 层转成 ok:false
|
|
139
|
+
export async function browseDirectory(args = {}) {
|
|
140
|
+
let p = typeof args.path === 'string' ? args.path : ''
|
|
141
|
+
const showHidden = args.hidden === true || args.hidden === '1' || args.hidden === 1
|
|
142
|
+
if (!p) p = process.env.HOME || '/'
|
|
143
|
+
if (!path.isAbsolute(p)) throw new Error('必须为绝对路径')
|
|
144
|
+
p = path.resolve(p)
|
|
145
|
+
try {
|
|
146
|
+
const entries = await fsp.readdir(p, { withFileTypes: true })
|
|
147
|
+
const parent = path.dirname(p)
|
|
148
|
+
const dirs = entries.filter(d => d.isDirectory() && (showHidden || !d.name.startsWith('.'))).map(d => d.name).sort().slice(0, 200)
|
|
149
|
+
// 若当前目录属于 git 仓库(含子目录/任意 worktree),列出全部 worktree 供前端切换
|
|
150
|
+
const worktrees = await listWorktrees(p)
|
|
151
|
+
return { path: p, parent, dirs, worktrees }
|
|
152
|
+
} catch {
|
|
153
|
+
throw new Error('目录不可读')
|
|
154
|
+
}
|
|
155
|
+
}
|