dsh-app-deploy 0.3.0 → 0.4.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 +5 -1
- package/client.js +33 -3
- package/index.js +9 -2
- package/package.json +1 -1
- package/server/config.js +33 -0
- package/server/deployment.js +100 -43
- package/server/tools.js +139 -0
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
插件发布在 npm,通过 DSH 插件管理器直接安装:
|
|
32
32
|
|
|
33
33
|
```text
|
|
34
|
-
plugin_manager → action: install_bundle → target: dsh-app-deploy@0.
|
|
34
|
+
plugin_manager → action: install_bundle → target: dsh-app-deploy@0.4.0
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
安装后刷新 DSH Web,左侧导航出现「部署」入口。
|
|
@@ -49,6 +49,10 @@ plugin_manager → action: install_bundle → target: dsh-app-deploy@0.3.0
|
|
|
49
49
|
|
|
50
50
|
新装插件默认为空配置(不播种),环境通过「+ 添加环境」或「导入」创建。
|
|
51
51
|
|
|
52
|
+
## AI 工具
|
|
53
|
+
|
|
54
|
+
插件向 dsh Agent 注册 4 个工具:`deploy_list_envs`(列出环境与模块,密码已剥离)、`deploy_run`(执行部署并返回日志)、`deploy_history`(执行记录)、`ssh_test_connection`(SSH 连通性测试)。可直接让 AI 代查发布状态或执行部署。
|
|
55
|
+
|
|
52
56
|
## 使用
|
|
53
57
|
|
|
54
58
|
单模块部署流程:
|
package/client.js
CHANGED
|
@@ -74,7 +74,7 @@ window.__ModuleLoader__.load({
|
|
|
74
74
|
while (modules.some((m) => m.id === 'mod' + n)) n++
|
|
75
75
|
return {
|
|
76
76
|
id: 'mod' + n, name: '新模块', kind: 'front', enabled: true,
|
|
77
|
-
buildCmd: '', sshId: '', localPath: '', remotePath: '',
|
|
77
|
+
buildCmd: '', sshId: '', instances: [], localPath: '', remotePath: '',
|
|
78
78
|
postCommand: '', healthCheckUrl: '', frontEnv: '',
|
|
79
79
|
}
|
|
80
80
|
}
|
|
@@ -83,6 +83,7 @@ window.__ModuleLoader__.load({
|
|
|
83
83
|
id: m.id || '', name: m.name || '', kind: m.kind === 'server' ? 'server' : 'front',
|
|
84
84
|
enabled: m.enabled !== false,
|
|
85
85
|
buildCmd: m.buildCmd || '', sshId: m.sshId || '',
|
|
86
|
+
instances: Array.isArray(m.instances) ? m.instances.map((i) => ({ sshId: i.sshId || '', healthCheckUrl: i.healthCheckUrl || '' })) : [],
|
|
86
87
|
localPath: m.localPath || '', remotePath: m.remotePath || '', postCommand: m.postCommand || '',
|
|
87
88
|
healthCheckUrl: m.healthCheckUrl || '', frontEnv: m.frontEnv || '',
|
|
88
89
|
}
|
|
@@ -326,6 +327,28 @@ window.__ModuleLoader__.load({
|
|
|
326
327
|
}))),
|
|
327
328
|
h('div', { className: 'dad-hint' },
|
|
328
329
|
'健康检查:HTTP 2xx、success=true、code=200;单次最多 5 秒,间隔 3 秒,最多等待 120 秒,失败中止后续模块。'),
|
|
330
|
+
h('div', { className: 'dad-instbox' },
|
|
331
|
+
h('div', { className: 'dad-insthead' },
|
|
332
|
+
h('span', { className: 'dad-label' }, '多实例(滚动发布:按顺序逐实例更新,探活通过才更新下一个;留空 = 单实例)'),
|
|
333
|
+
h('button', {
|
|
334
|
+
type: 'button', className: 'dad-btn', onClick: () => onChange('instances', [...(mod.instances || []), { sshId: (sshs[0] || {}).id || '', healthCheckUrl: '' }]),
|
|
335
|
+
}, '+ 添加实例')),
|
|
336
|
+
(mod.instances || []).map((inst, ii) => h('div', { key: ii, className: 'dad-inline', style: { marginBottom: '6px' } },
|
|
337
|
+
h('select', {
|
|
338
|
+
className: 'dad-select', value: inst.sshId, 'aria-label': '实例 ' + (ii + 1) + ' SSH 目标',
|
|
339
|
+
onChange: (ev) => onChange('instances', (mod.instances || []).map((x, j) => (j === ii ? { ...x, sshId: ev.target.value } : x))),
|
|
340
|
+
},
|
|
341
|
+
(sshs || []).map((s) => h('option', { key: s.id, value: s.id }, s.name || s.id))),
|
|
342
|
+
h('input', {
|
|
343
|
+
className: 'dad-input', type: 'text', value: inst.healthCheckUrl, placeholder: '探活 URL(可空,默认用模块的)',
|
|
344
|
+
'aria-label': '实例 ' + (ii + 1) + ' 健康检查 URL',
|
|
345
|
+
onChange: (ev) => onChange('instances', (mod.instances || []).map((x, j) => (j === ii ? { ...x, healthCheckUrl: ev.target.value } : x))),
|
|
346
|
+
}),
|
|
347
|
+
h('button', {
|
|
348
|
+
type: 'button', className: 'dad-btndel', title: '删除实例', 'aria-label': '删除实例 ' + (ii + 1),
|
|
349
|
+
style: { position: 'static' },
|
|
350
|
+
onClick: () => onChange('instances', (mod.instances || []).filter((_, j) => j !== ii)),
|
|
351
|
+
}, '×')))),
|
|
329
352
|
mod.kind === 'front'
|
|
330
353
|
? h('div', { className: 'dad-row' },
|
|
331
354
|
h(Field, { label: feLabel, wide: true },
|
|
@@ -1067,6 +1090,7 @@ window.__ModuleLoader__.load({
|
|
|
1067
1090
|
enabled: !!m.enabled,
|
|
1068
1091
|
buildCmd: (m.buildCmd || '').trim(),
|
|
1069
1092
|
sshId: (m.sshId || '').trim(),
|
|
1093
|
+
instances: (m.instances || []).map((i) => ({ sshId: (i.sshId || '').trim(), healthCheckUrl: (i.healthCheckUrl || '').trim() })),
|
|
1070
1094
|
localPath: (m.localPath || '').trim(),
|
|
1071
1095
|
remotePath: (m.remotePath || '').trim(),
|
|
1072
1096
|
postCommand: (m.postCommand || '').trim(),
|
|
@@ -1238,9 +1262,15 @@ window.__ModuleLoader__.load({
|
|
|
1238
1262
|
const exp = m.kind === 'server' && env.expTime && ((m.buildCmd || '').trim().split(/\s+/)[0] || '').includes('gradle') ? ' -PexpTime=' + env.expTime : ''
|
|
1239
1263
|
lines.push('')
|
|
1240
1264
|
lines.push('[' + (m.name || m.id) + ']')
|
|
1241
|
-
const target = targetOf(env, m)
|
|
1242
1265
|
lines.push(' 构建: ' + m.buildCmd + exp)
|
|
1243
|
-
|
|
1266
|
+
const insts = (m.instances || []).length
|
|
1267
|
+
? m.instances.map((inst, ii) => ({ target: targetOf(env, { sshId: inst.sshId }), healthCheckUrl: inst.healthCheckUrl || m.healthCheckUrl, label: '实例' + (ii + 1) }))
|
|
1268
|
+
: [{ target: targetOf(env, m), healthCheckUrl: m.healthCheckUrl, label: '' }]
|
|
1269
|
+
for (const inst of insts) {
|
|
1270
|
+
const t = inst.target
|
|
1271
|
+
lines.push(' ' + (inst.label ? inst.label + ' ' : '') + 'rsync: ' + m.localPath + ' → ' + (t.user || 'root') + '@' + (t.host || '?') + ':' + m.remotePath + (inst.label ? '' : (env.sshs && env.sshs.length > 1 ? '(目标: ' + (t.name || t.id) + ')' : '')))
|
|
1272
|
+
if (inst.healthCheckUrl) lines.push(' 探活: ' + inst.healthCheckUrl)
|
|
1273
|
+
}
|
|
1244
1274
|
if (m.postCommand) lines.push(' ssh: ' + m.postCommand)
|
|
1245
1275
|
}
|
|
1246
1276
|
return lines.join('\n')
|
package/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { initConfig } from './server/config.js'
|
|
|
7
7
|
import { initHistory } from './server/history.js'
|
|
8
8
|
import { handleRpc } from './server/rpc.js'
|
|
9
9
|
import { handleDeploy, killActiveProcess } from './server/deployment.js'
|
|
10
|
+
import { registerTools } from './server/tools.js'
|
|
10
11
|
|
|
11
12
|
// ponytail: linked workspace cannot resolve dsh's own packages; resolve from the dsh entry instead
|
|
12
13
|
function hostRequire() {
|
|
@@ -22,7 +23,7 @@ const host = hostRequire()
|
|
|
22
23
|
const schema = host('@deepseek-ai/schemastery')
|
|
23
24
|
const z = schema.z ?? schema
|
|
24
25
|
|
|
25
|
-
export const inject = ['webServer']
|
|
26
|
+
export const inject = ['webServer', 'tools']
|
|
26
27
|
export const Config = z.object({
|
|
27
28
|
dataDir: z.string().default(''),
|
|
28
29
|
defaultProjectPath: z.string().default(''),
|
|
@@ -59,13 +60,19 @@ function resolveDataDir(config) {
|
|
|
59
60
|
|
|
60
61
|
export function apply(ctx, config) {
|
|
61
62
|
const dataDir = resolveDataDir(config)
|
|
62
|
-
// 延迟初始化:apply 内不 await
|
|
63
|
+
// 延迟初始化:apply 内不 await,首个请求/工具调用时才读数据目录/初始化配置
|
|
63
64
|
let ready
|
|
64
65
|
const ensure = () => (ready ??= (async () => {
|
|
65
66
|
await mkdir(dataDir, { recursive: true })
|
|
66
67
|
await initConfig({ dataDir, defaultProjectPath: config?.defaultProjectPath || '' })
|
|
67
68
|
await initHistory(dataDir)
|
|
68
69
|
})())
|
|
70
|
+
// AI 工具:列出环境 / 执行部署 / 执行记录 / SSH 测试(defineTool 从 dsh 宿主解析,失败不影响面板)
|
|
71
|
+
try {
|
|
72
|
+
registerTools(ctx, host('@deepseek-ai/dsh-tools').defineTool, ensure)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.warn('[dsh-app-deploy] 注册 AI 工具失败:', err?.message || err)
|
|
75
|
+
}
|
|
69
76
|
|
|
70
77
|
ctx.effect(() => {
|
|
71
78
|
const dispose = ctx.webServer.register({
|
package/package.json
CHANGED
package/server/config.js
CHANGED
|
@@ -64,6 +64,27 @@ export function targetFor(env, m) {
|
|
|
64
64
|
return list.find((s) => s.id === m?.sshId) || list[0]
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// 模块的多实例发布列表:每个实例指定 SSH 目标与探活 URL(URL 可空 = 该实例不探活直接继续);
|
|
68
|
+
// 无实例配置 = 模块自身单实例(用模块默认目标与健康检查 URL)
|
|
69
|
+
export function instanceList(env, m) {
|
|
70
|
+
if (!Array.isArray(m?.instances) || !m.instances.length) {
|
|
71
|
+
return [{ target: targetFor(env, m), healthCheckUrl: (m?.healthCheckUrl || '').trim(), label: '' }]
|
|
72
|
+
}
|
|
73
|
+
return m.instances.map((inst, i) => ({
|
|
74
|
+
target: targetFor(env, { ...m, sshId: inst.sshId }),
|
|
75
|
+
healthCheckUrl: (inst.healthCheckUrl || m.healthCheckUrl || '').trim(),
|
|
76
|
+
label: `实例${i + 1}`,
|
|
77
|
+
}))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 实例数组归一化:只保留带 sshId 的项
|
|
81
|
+
function normInstances(raw) {
|
|
82
|
+
if (!Array.isArray(raw)) return []
|
|
83
|
+
return raw
|
|
84
|
+
.map((i) => ({ sshId: String(i?.sshId || ''), healthCheckUrl: String(i?.healthCheckUrl || '').trim() }))
|
|
85
|
+
.filter((i) => i.sshId)
|
|
86
|
+
}
|
|
87
|
+
|
|
67
88
|
// 把 modules 归一化为数组;record 旧格式自动转数组(id 保持 bsFront 等,name 映射),frontEnv.bs/admin 迁移进对应 front 模块
|
|
68
89
|
function toModArray(mods, fe) {
|
|
69
90
|
const out = []
|
|
@@ -78,6 +99,7 @@ function toModArray(mods, fe) {
|
|
|
78
99
|
enabled: raw?.enabled !== false,
|
|
79
100
|
buildCmd: String(raw?.buildCmd || cmdFromGradle(raw?.gradleTask, raw?.key, raw?.extraArgs)),
|
|
80
101
|
sshId: String(raw?.sshId || ''),
|
|
102
|
+
instances: normInstances(raw?.instances),
|
|
81
103
|
localPath: String(raw?.localPath || ''),
|
|
82
104
|
remotePath: String(raw?.remotePath || ''),
|
|
83
105
|
postCommand: String(raw?.postCommand || ''),
|
|
@@ -95,6 +117,7 @@ function toModArray(mods, fe) {
|
|
|
95
117
|
enabled: m.enabled !== false,
|
|
96
118
|
buildCmd: String(m.buildCmd || cmdFromGradle(m.gradleTask, m.key, m.extraArgs)),
|
|
97
119
|
sshId: String(m.sshId || ''),
|
|
120
|
+
instances: normInstances(m.instances),
|
|
98
121
|
localPath: String(m.localPath || ''), remotePath: String(m.remotePath || ''), postCommand: String(m.postCommand || ''),
|
|
99
122
|
frontEnv: feText,
|
|
100
123
|
})
|
|
@@ -257,6 +280,16 @@ export function validateEnv(e) {
|
|
|
257
280
|
}
|
|
258
281
|
if (!m.enabled) continue
|
|
259
282
|
if (m.sshId && !sshList.some((s) => s.id === m.sshId)) errs.push(`${m.name || m.id}: SSH 目标「${m.sshId}」不存在`)
|
|
283
|
+
for (let ii = 0; ii < (m.instances || []).length; ii++) {
|
|
284
|
+
const inst = m.instances[ii]
|
|
285
|
+
if (!sshList.some((s) => s.id === inst.sshId)) errs.push(`${m.name || m.id}: 实例${ii + 1} 的 SSH 目标「${inst.sshId}」不存在`)
|
|
286
|
+
if (inst.healthCheckUrl) {
|
|
287
|
+
try {
|
|
288
|
+
const url = new URL(inst.healthCheckUrl)
|
|
289
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('invalid URL')
|
|
290
|
+
} catch { errs.push(`${m.name || m.id}: 实例${ii + 1} 的健康检查 URL 必须是 HTTP/HTTPS 地址,且不能包含用户名或密码`) }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
260
293
|
if (!m.buildCmd.trim()) errs.push(`${m.name || m.id}: 构建命令必填(如 ./gradlew …、mvn …、npm run build)`)
|
|
261
294
|
else if (BAD_CMD.test(m.buildCmd)) errs.push(`${m.name || m.id}: 构建命令含非法字符(禁止引号/; & | \` $ 等)`)
|
|
262
295
|
if (!m.localPath) errs.push(`${m.name || m.id}: 本地产物路径必填`)
|
package/server/deployment.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
2
|
import { promises as fsp, existsSync } from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
-
import { getEnvs,
|
|
4
|
+
import { getEnvs, instanceList } from './config.js'
|
|
5
5
|
import { getHistory, saveHistory, LOGTAIL_CHARS } from './history.js'
|
|
6
6
|
import { listWorktrees, which, rcloneObscure } from './local-tools.js'
|
|
7
7
|
import { checkHealth } from './health-check.js'
|
|
@@ -238,12 +238,10 @@ export function buildRcloneArgs(target, m, local, rclone) {
|
|
|
238
238
|
return args
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
//
|
|
241
|
+
// 返回退出码与失败阶段(清理/构建/同步/远程命令/健康检查),用于执行记录
|
|
242
242
|
// rclone 非空时用 rclone(SFTP)替代 rsync 同步;rsyncEnv 同时承载 sshpass 的 SSHPASS
|
|
243
|
-
//
|
|
244
|
-
export async function deployModule(env, m,
|
|
245
|
-
const sshBase = `ssh -p ${target.port}${target.keyFile ? ' -i ' + target.keyFile : ''}${target.options ? ' ' + target.options : ''}`
|
|
246
|
-
const dest = `${target.user}@${target.host}`
|
|
243
|
+
// 发布目标不再作为参数:模块可能多实例(instanceList),逐实例在内部解析
|
|
244
|
+
export async function deployModule(env, m, sshpass, rclone = null) {
|
|
247
245
|
// 0. 清理构建目录(恢复原 update-*.sh 行为)
|
|
248
246
|
const c0 = await cleanBuildDir(env, m)
|
|
249
247
|
if (c0 !== 0) return { code: c0, step: '清理' }
|
|
@@ -284,38 +282,52 @@ export async function deployModule(env, m, target, sshpass, rclone = null) {
|
|
|
284
282
|
const c1 = await runStep(`[${m.name}] 构建: ${buildArgs.join(' ')} ${noteSuffix}`, buildArgs, { cwd: env.projectPath, env: envForGradle, shell: IS_WIN })
|
|
285
283
|
if (c1 !== 0) { broadcast(`[${m.name}] 构建失败,退出码 ${c1},中止部署\n`); return { code: c1, step: '构建' } }
|
|
286
284
|
|
|
287
|
-
// 2.
|
|
285
|
+
// 2/3. 多实例滚动发布:按实例列表顺序,逐个 同步 → 远程命令 → 探活;探活通过才更新下一个实例。
|
|
286
|
+
// 无实例配置 = 单实例(模块默认目标与健康检查 URL)。
|
|
288
287
|
const isFront = m.kind === 'front'
|
|
289
288
|
// 保留 rsync 的目录尾斜杠语义:dist/ 传内容本身,dist 传 dist 目录
|
|
290
289
|
const isDirLike = /[\\/]$/.test(m.localPath)
|
|
291
290
|
const joined = path.isAbsolute(m.localPath) ? m.localPath : path.join(env.projectPath, m.localPath)
|
|
292
291
|
const local = joined.replace(/[\\/]+$/, '') + (isDirLike ? '/' : '')
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
292
|
+
const instances = instanceList(env, m)
|
|
293
|
+
if (instances.length > 1) broadcast(`[${m.name}] 多实例滚动发布:共 ${instances.length} 个实例,探活通过才更新下一个\n`)
|
|
294
|
+
for (let ii = 0; ii < instances.length; ii++) {
|
|
295
|
+
const inst = instances[ii]
|
|
296
|
+
const t = inst.target
|
|
297
|
+
const prefix = instances.length > 1 ? `[${m.name} ${inst.label}]` : `[${m.name}]`
|
|
298
|
+
const sshBase = `ssh -p ${t.port}${t.keyFile ? ' -i ' + t.keyFile : ''}${t.options ? ' ' + t.options : ''}`
|
|
299
|
+
const dest = `${t.user}@${t.host}`
|
|
300
|
+
// 仅当该实例目标配置了密码才挂 sshpass(避免空 SSHPASS 破坏密钥/agent 认证)
|
|
301
|
+
const usePass = sshpass && t.password
|
|
302
|
+
const rsyncEnv = usePass ? { ...process.env, SSHPASS: t.password } : process.env
|
|
303
|
+
// 2. 同步:优先 rsync;本机没有时回退 rclone(--delete 仅用于前端模块,与原脚本行为一致)
|
|
304
|
+
let c2
|
|
305
|
+
if (rclone) {
|
|
306
|
+
const args = buildRcloneArgs(t, m, local, rclone)
|
|
307
|
+
c2 = await runStep(`${prefix} rclone ${isFront ? 'sync' : 'copy'} ${local} → :sftp:${m.remotePath}${isFront ? '(镜像 --delete)' : ''}`, [rclone.bin, ...args], {})
|
|
308
|
+
} else {
|
|
309
|
+
const rsyncArgs = ['rsync', '-r', '-t', '-z', '-e', usePass ? `sshpass -e ${sshBase}` : sshBase]
|
|
310
|
+
if (isFront) rsyncArgs.push('--delete', '--exclude=.user.ini') // 排除宝塔 .user.ini(chattr +i 锁定,--delete 无法删除且会导致退出码非 0)
|
|
311
|
+
rsyncArgs.push(local, `${dest}:${m.remotePath}`)
|
|
312
|
+
c2 = await runStep(`${prefix} rsync ${local} → ${dest}:${m.remotePath}${isFront ? '(--delete)' : ''}`, rsyncArgs, { env: rsyncEnv })
|
|
313
|
+
}
|
|
314
|
+
if (c2 !== 0) { broadcast(`${prefix} 同步失败,退出码 ${c2},中止部署\n`); return { code: c2, step: instances.length > 1 ? `同步(${inst.label})` : '同步' } }
|
|
305
315
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
316
|
+
// 3. ssh 远程命令
|
|
317
|
+
if (m.postCommand.trim()) {
|
|
318
|
+
const sshArgs = []
|
|
319
|
+
if (usePass) sshArgs.push('sshpass', '-e')
|
|
320
|
+
sshArgs.push('ssh', '-p', String(t.port))
|
|
321
|
+
if (t.keyFile) sshArgs.push('-i', t.keyFile)
|
|
322
|
+
if (t.options) sshArgs.push(...t.options.trim().split(/\s+/).filter(Boolean))
|
|
323
|
+
sshArgs.push(dest, m.postCommand.trim())
|
|
324
|
+
const c3 = await runStep(`${prefix} 远程执行: ${m.postCommand.trim()}`, sshArgs, { env: rsyncEnv })
|
|
325
|
+
if (c3 !== 0) { broadcast(`${prefix} 远程命令失败,退出码 ${c3},中止部署\n`); return { code: c3, step: instances.length > 1 ? `远程命令(${inst.label})` : '远程命令' } }
|
|
326
|
+
}
|
|
327
|
+
// 探活:通过才继续下一个实例(滚动发布闸门)
|
|
328
|
+
if (!await checkHealth(inst.healthCheckUrl || '', message => broadcast(`${prefix} ${message}`))) {
|
|
329
|
+
return { code: 1, step: instances.length > 1 ? `健康检查(${inst.label})` : '健康检查' }
|
|
330
|
+
}
|
|
319
331
|
}
|
|
320
332
|
return { code: 0, step: null }
|
|
321
333
|
}
|
|
@@ -329,7 +341,7 @@ async function runDeployment(env, mods) {
|
|
|
329
341
|
const rcloneBin = hasRsync ? null : await which('rclone')
|
|
330
342
|
const needSsh = mods.some(m => m.enabled && m.postCommand.trim())
|
|
331
343
|
// 每个启用的模块按其 sshId 解析发布目标(env.sshs 之一)
|
|
332
|
-
const usedTargets = [...new Set(mods.filter(m => m.enabled).
|
|
344
|
+
const usedTargets = [...new Set(mods.filter(m => m.enabled).flatMap(m => instanceList(env, m).map(i => i.target)))]
|
|
333
345
|
const missing = []
|
|
334
346
|
if (!hasRsync && !rcloneBin) missing.push('rsync(或 rclone)')
|
|
335
347
|
if ((hasRsync || needSsh) && !(await which('ssh'))) missing.push('ssh')
|
|
@@ -354,17 +366,21 @@ async function runDeployment(env, mods) {
|
|
|
354
366
|
code = 0
|
|
355
367
|
for (const m of mods) {
|
|
356
368
|
if (!m.enabled) { broadcast(`[跳过] ${m.name} 已禁用\n`); continue }
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
if (!
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
369
|
+
// 预检该模块所有实例目标的 rclone 密码(逐实例 obscure,按密码缓存)
|
|
370
|
+
for (const inst of instanceList(env, m)) {
|
|
371
|
+
const target = inst.target
|
|
372
|
+
if (rclone && target.password && !passCache.has(target.password)) {
|
|
373
|
+
const pass = await rcloneObscure(rcloneBin, target.password)
|
|
374
|
+
if (!pass) {
|
|
375
|
+
broadcast(`错误: rclone obscure 密码处理失败(目标 ${target.name}),无法使用密码认证\n`)
|
|
376
|
+
code = 127
|
|
377
|
+
break
|
|
378
|
+
}
|
|
379
|
+
passCache.set(target.password, pass)
|
|
364
380
|
}
|
|
365
|
-
passCache.set(target.password, pass)
|
|
366
381
|
}
|
|
367
|
-
|
|
382
|
+
if (code !== 0) break
|
|
383
|
+
const res = await deployModule(env, m, sshpass, rclone)
|
|
368
384
|
if (res.code !== 0) {
|
|
369
385
|
if (running?.record) { running.record.failedModule = m.name; running.record.failedStep = res.step }
|
|
370
386
|
code = res.code
|
|
@@ -405,7 +421,7 @@ export async function handleDeploy(req, res, url) {
|
|
|
405
421
|
const mods = orderIds.map(id => ordered.find(m => m.id === id)).filter(m => !!m)
|
|
406
422
|
if (!mods.length) return bad('所选模块在环境中均不可用或已禁用')
|
|
407
423
|
// 所选模块涉及的 SSH 目标都必须有 host/user
|
|
408
|
-
const runTargets = [...new Set(mods.
|
|
424
|
+
const runTargets = [...new Set(mods.flatMap(m => instanceList(env, m).map(i => i.target)))]
|
|
409
425
|
if (runTargets.some(t => !t.host || !t.user)) return bad('SSH 目标配置不完整')
|
|
410
426
|
|
|
411
427
|
const sseHeaders = { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }
|
|
@@ -463,4 +479,45 @@ export async function handleDeploy(req, res, url) {
|
|
|
463
479
|
}
|
|
464
480
|
}
|
|
465
481
|
|
|
482
|
+
// 供 AI 工具调用:同步执行一次部署并返回结果;与 SSE 入口共用部署锁、执行记录与日志尾部
|
|
483
|
+
export async function runDeploy(envId, moduleIds = []) {
|
|
484
|
+
const env = getEnvs().find(e => e.id === envId)
|
|
485
|
+
if (!env) throw new Error(`未知环境: ${envId}`)
|
|
486
|
+
if (running && !running.done) throw new Error(`已有部署进行中(${running.label}),请稍后再试`)
|
|
487
|
+
const want = (moduleIds || []).filter(Boolean)
|
|
488
|
+
const enabled = env.modules.filter(m => m.enabled)
|
|
489
|
+
// 未指定模块 = 全部启用模块(按配置顺序);指定则按给定顺序
|
|
490
|
+
const mods = want.length
|
|
491
|
+
? want.map(id => enabled.find(m => m.id === id)).filter(Boolean)
|
|
492
|
+
: enabled
|
|
493
|
+
if (!mods.length) throw new Error('没有可部署的启用模块(检查模块 ID 与启用状态)')
|
|
494
|
+
if (want.length && mods.length !== want.length) {
|
|
495
|
+
const missing = want.filter(id => !enabled.some(m => m.id === id))
|
|
496
|
+
throw new Error(`模块不可用或已禁用: ${missing.join('、')}`)
|
|
497
|
+
}
|
|
498
|
+
const runTargets = [...new Set(mods.flatMap(m => instanceList(env, m).map(i => i.target)))]
|
|
499
|
+
if (runTargets.some(t => !t.host || !t.user)) throw new Error('SSH 目标配置不完整')
|
|
500
|
+
|
|
501
|
+
const rec = {
|
|
502
|
+
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
|
503
|
+
envId: env.id, envName: env.name,
|
|
504
|
+
modules: mods.map(m => ({ id: m.id, name: m.name })),
|
|
505
|
+
startTime: Date.now(), endTime: null, durationMs: null,
|
|
506
|
+
status: 'running', exitCode: null, failedModule: null, failedStep: null, logTail: '',
|
|
507
|
+
}
|
|
508
|
+
getHistory().push(rec)
|
|
509
|
+
saveHistory().catch(() => {})
|
|
510
|
+
running = { clients: new Set(), done: false, label: `${env.name} / ${mods.map(m => m.name).join('+')}`, record: rec, logTail: '', lastCode: null }
|
|
511
|
+
broadcast(`>>> 开始部署 [AI 工具] ${env.name} / ${mods.map(m => m.name).join('+')}\n`)
|
|
512
|
+
await runDeployment(env, mods) // 结束后 running.done 已置位、rec 已补齐
|
|
513
|
+
return {
|
|
514
|
+
code: rec.exitCode,
|
|
515
|
+
status: rec.status,
|
|
516
|
+
durationMs: rec.durationMs,
|
|
517
|
+
failedModule: rec.failedModule,
|
|
518
|
+
failedStep: rec.failedStep,
|
|
519
|
+
logTail: rec.logTail,
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
466
523
|
export function activeRecord() { return running && !running.done ? running.record : null }
|
package/server/tools.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// AI 可调用的部署工具:列出环境、执行部署、查执行记录、测试 SSH 连接
|
|
2
|
+
// 密码绝不暴露给模型(只返回 hasPassword 标记);密钥路径是本机路径,可返回
|
|
3
|
+
import { getEnvs } from './config.js'
|
|
4
|
+
import { runDeploy, testSsh, activeRecord } from './deployment.js'
|
|
5
|
+
import { getHistory } from './history.js'
|
|
6
|
+
|
|
7
|
+
// 环境摘要:供模型决定部署目标;剥离 password
|
|
8
|
+
export function envSummaryForModel(e) {
|
|
9
|
+
return {
|
|
10
|
+
id: e.id, name: e.name, note: e.note, group: e.group,
|
|
11
|
+
projectPath: e.projectPath, jdkHome: e.jdkHome || '', nodeHome: e.nodeHome || '', pmPath: e.pmPath || '',
|
|
12
|
+
sshs: (e.sshs || []).map(s => ({
|
|
13
|
+
id: s.id, name: s.name, host: s.host, port: s.port, user: s.user,
|
|
14
|
+
hasPassword: !!s.password, keyFile: s.keyFile || '', options: s.options || '',
|
|
15
|
+
})),
|
|
16
|
+
modules: (e.modules || []).map(m => ({
|
|
17
|
+
id: m.id, name: m.name, kind: m.kind, enabled: m.enabled, buildCmd: m.buildCmd, sshId: m.sshId || '',
|
|
18
|
+
localPath: m.localPath, remotePath: m.remotePath, postCommand: m.postCommand, healthCheckUrl: m.healthCheckUrl || '',
|
|
19
|
+
instances: (m.instances || []).map((i) => ({ sshId: i.sshId, healthCheckUrl: i.healthCheckUrl })),
|
|
20
|
+
})),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const jsonRender = (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }]
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 向 ctx.tools 注册 4 个工具。defineTool 由宿主解析(@deepseek-ai/dsh-tools),
|
|
28
|
+
* 插件自身不依赖该包,保证脱离 dsh 宿主时模块仍可加载(如单元测试)。
|
|
29
|
+
* @param {object} ctx cordis Context(需注入 tools 服务)
|
|
30
|
+
* @param {object} defineTool @deepseek-ai/dsh-tools 的 defineTool
|
|
31
|
+
* @param {() => Promise<void>} ensure 插件初始化(数据目录/配置/历史);工具调用不走 HTTP 路由,必须显式等待
|
|
32
|
+
*/
|
|
33
|
+
export function registerTools(ctx, defineTool, ensure) {
|
|
34
|
+
// 每个工具执行前确保初始化完成(幂等,首次才真正读盘)
|
|
35
|
+
const ready = () => (ensure ? ensure() : Promise.resolve())
|
|
36
|
+
ctx.tools.register(defineTool({
|
|
37
|
+
name: 'deploy_list_envs',
|
|
38
|
+
description: 'List deploy environments configured in the dsh-app-deploy plugin: SSH targets (passwords stripped), build modules with their build commands, artifact paths, publish targets and rolling-update instances. Use before deploy_run to pick env/module IDs.',
|
|
39
|
+
parameters: {},
|
|
40
|
+
output: {
|
|
41
|
+
schema: {
|
|
42
|
+
type: 'object', additionalProperties: false,
|
|
43
|
+
properties: {
|
|
44
|
+
active: { type: 'object', additionalProperties: true }, // 进行中的部署(无则不返回)
|
|
45
|
+
envs: { type: 'array', required: true, items: { type: 'object', additionalProperties: true } },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
render: jsonRender,
|
|
49
|
+
},
|
|
50
|
+
execute: async () => {
|
|
51
|
+
await ready()
|
|
52
|
+
const rec = activeRecord()
|
|
53
|
+
return {
|
|
54
|
+
...(rec ? { active: { id: rec.id, env: rec.envName, modules: rec.modules } } : {}),
|
|
55
|
+
envs: getEnvs().map(envSummaryForModel),
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
}))
|
|
59
|
+
|
|
60
|
+
ctx.tools.register(defineTool({
|
|
61
|
+
name: 'deploy_run',
|
|
62
|
+
description: 'Run a deployment for one environment: clean → build each module (generic command: gradlew/mvn/npm…) → rsync/rclone to the module\'s SSH target → remote command → health check. Blocks until finished and returns the exit code plus the full log tail. Fails if another deployment is running. Omit modules to deploy all enabled modules in config order.',
|
|
63
|
+
parameters: {
|
|
64
|
+
env: { type: 'string', required: true, description: 'Environment ID (from deploy_list_envs)' },
|
|
65
|
+
modules: { type: 'array', items: { type: 'string' }, description: 'Module IDs in desired execution order. Omit for all enabled modules.' },
|
|
66
|
+
},
|
|
67
|
+
output: {
|
|
68
|
+
schema: {
|
|
69
|
+
type: 'object', additionalProperties: false,
|
|
70
|
+
properties: {
|
|
71
|
+
code: { type: 'integer', required: true },
|
|
72
|
+
status: { type: 'string', required: true },
|
|
73
|
+
durationMs: { type: 'integer' },
|
|
74
|
+
failedModule: { type: 'string' },
|
|
75
|
+
failedStep: { type: 'string' },
|
|
76
|
+
logTail: { type: 'string', required: true },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
render: jsonRender,
|
|
80
|
+
},
|
|
81
|
+
execute: async (args) => {
|
|
82
|
+
await ready()
|
|
83
|
+
return runDeploy(args.env, args.modules || [])
|
|
84
|
+
},
|
|
85
|
+
}))
|
|
86
|
+
|
|
87
|
+
ctx.tools.register(defineTool({
|
|
88
|
+
name: 'deploy_history',
|
|
89
|
+
description: 'Recent deployment records (newest first): status, duration, per-module failure step, and a truncated log tail for failed runs. Filter by environment ID optionally.',
|
|
90
|
+
parameters: {
|
|
91
|
+
env: { type: 'string', description: 'Environment ID to filter by' },
|
|
92
|
+
limit: { type: 'integer', description: 'Max records, default 10' },
|
|
93
|
+
},
|
|
94
|
+
output: {
|
|
95
|
+
schema: {
|
|
96
|
+
type: 'object', additionalProperties: false,
|
|
97
|
+
properties: {
|
|
98
|
+
records: { type: 'array', required: true, items: { type: 'object', additionalProperties: true } },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
render: jsonRender,
|
|
102
|
+
},
|
|
103
|
+
execute: async (args) => {
|
|
104
|
+
await ready()
|
|
105
|
+
const limit = Math.min(Math.max(Number(args.limit) || 10, 1), 50)
|
|
106
|
+
let list = getHistory().slice()
|
|
107
|
+
if (args.env) list = list.filter(r => r.envId === args.env)
|
|
108
|
+
return { records: list.slice(-limit).reverse() }
|
|
109
|
+
},
|
|
110
|
+
}))
|
|
111
|
+
|
|
112
|
+
ctx.tools.register(defineTool({
|
|
113
|
+
name: 'ssh_test_connection',
|
|
114
|
+
description: 'Test an SSH connection (10s timeout): host key/agent auth via keyFile, or password via sshpass. Returns ok/latency or the server-reported error.',
|
|
115
|
+
parameters: {
|
|
116
|
+
host: { type: 'string', required: true },
|
|
117
|
+
port: { type: 'integer' },
|
|
118
|
+
user: { type: 'string', required: true },
|
|
119
|
+
password: { type: 'string', description: 'SSH password; omit to use keyFile or agent' },
|
|
120
|
+
keyFile: { type: 'string', description: 'Absolute private key path; takes precedence over options -i' },
|
|
121
|
+
options: { type: 'string', description: 'Extra ssh options, e.g. -o StrictHostKeyChecking=no' },
|
|
122
|
+
},
|
|
123
|
+
output: {
|
|
124
|
+
schema: {
|
|
125
|
+
type: 'object', additionalProperties: false,
|
|
126
|
+
properties: {
|
|
127
|
+
ok: { type: 'boolean', required: true },
|
|
128
|
+
ms: { type: 'integer' },
|
|
129
|
+
error: { type: 'string' },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
render: jsonRender,
|
|
133
|
+
},
|
|
134
|
+
execute: async (args) => {
|
|
135
|
+
await ready()
|
|
136
|
+
return testSsh({ host: args.host, port: args.port || 22, user: args.user, password: args.password || '', keyFile: args.keyFile || '', options: args.options || '' })
|
|
137
|
+
},
|
|
138
|
+
}))
|
|
139
|
+
}
|