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 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.3.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
- lines.push(' rsync: ' + m.localPath + ' → ' + (target.user || 'root') + '@' + (target.host || '?') + ':' + m.remotePath + (env.sshs && env.sshs.length > 1 ? '(目标: ' + (target.name || target.id) + ')' : ''))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-app-deploy",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "DSH 插件:按环境编排并执行构建(Gradle/Maven/前端)、多 SSH 目标发布、rsync/rclone 同步与远程命令的部署面板,支持连接测试,实时推送日志并保留执行记录。",
5
5
  "repository": "btboys/dsh-app-deploy",
6
6
  "type": "module",
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}: 本地产物路径必填`)
@@ -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, targetFor } from './config.js'
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
- // target 为模块选中的 SSH 目标(env.sshs 之一)
244
- export async function deployModule(env, m, target, sshpass, rclone = null) {
245
- const sshBase = `ssh -p ${target.port}${target.keyFile ? ' -i ' + target.keyFile : ''}${target.options ? ' ' + target.options : ''}`
246
- const dest = `${target.user}@${target.host}`
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. 同步:优先 rsync;本机没有时回退 rclone(--delete 仅用于前端模块,与原脚本行为一致)
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 rsyncEnv = sshpass ? { ...process.env, SSHPASS: target.password } : process.env
294
- let c2
295
- if (rclone) {
296
- const args = buildRcloneArgs(target, m, local, rclone)
297
- c2 = await runStep(`[${m.name}] rclone ${isFront ? 'sync' : 'copy'} ${local} → :sftp:${m.remotePath}${isFront ? '(镜像 --delete)' : ''}`, [rclone.bin, ...args], {})
298
- } else {
299
- const rsyncArgs = ['rsync', '-r', '-t', '-z', '-e', sshpass ? `sshpass -e ${sshBase}` : sshBase]
300
- if (isFront) rsyncArgs.push('--delete', '--exclude=.user.ini') // 排除宝塔 .user.ini(chattr +i 锁定,--delete 无法删除且会导致退出码非 0)
301
- rsyncArgs.push(local, `${dest}:${m.remotePath}`)
302
- c2 = await runStep(`[${m.name}] rsync ${local} → ${dest}:${m.remotePath}${isFront ? '(--delete)' : ''}`, rsyncArgs, { env: rsyncEnv })
303
- }
304
- if (c2 !== 0) { broadcast(`[${m.name}] 同步失败,退出码 ${c2},中止部署\n`); return { code: c2, step: '同步' } }
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
- // 3. ssh 远程命令
307
- if (m.postCommand.trim()) {
308
- const sshArgs = []
309
- if (sshpass) sshArgs.push('sshpass', '-e')
310
- sshArgs.push('ssh', '-p', String(target.port))
311
- if (target.keyFile) sshArgs.push('-i', target.keyFile)
312
- if (target.options) sshArgs.push(...target.options.trim().split(/\s+/).filter(Boolean))
313
- sshArgs.push(dest, m.postCommand.trim())
314
- const c3 = await runStep(`[${m.name}] 远程执行: ${m.postCommand.trim()}`, sshArgs, { env: rsyncEnv })
315
- if (c3 !== 0) { broadcast(`[${m.name}] 远程命令失败,退出码 ${c3},中止部署\n`); return { code: c3, step: '远程命令' } }
316
- }
317
- if (!await checkHealth(m.healthCheckUrl || '', message => broadcast(`[${m.name}] ${message}`))) {
318
- return { code: 1, step: '健康检查' }
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).map(m => targetFor(env, m)))]
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
- const target = targetFor(env, m)
358
- if (rclone && target.password && !passCache.has(target.password)) {
359
- const pass = await rcloneObscure(rcloneBin, target.password)
360
- if (!pass) {
361
- broadcast(`错误: rclone obscure 密码处理失败(目标 ${target.name}),无法使用密码认证\n`)
362
- code = 127
363
- break
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
- const res = await deployModule(env, m, target, target.password ? sshpass : null, rclone)
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.map(m => targetFor(env, m)))]
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 }
@@ -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
+ }