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.
@@ -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
+ }