dsh-app-deploy 0.3.1 → 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.1
34
+ plugin_manager → action: install_bundle → target: dsh-app-deploy@0.4.0
35
35
  ```
36
36
 
37
37
  安装后刷新 DSH Web,左侧导航出现「部署」入口。
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-app-deploy",
3
- "version": "0.3.1",
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' }
@@ -479,7 +495,7 @@ export async function runDeploy(envId, moduleIds = []) {
479
495
  const missing = want.filter(id => !enabled.some(m => m.id === id))
480
496
  throw new Error(`模块不可用或已禁用: ${missing.join('、')}`)
481
497
  }
482
- const runTargets = [...new Set(mods.map(m => targetFor(env, m)))]
498
+ const runTargets = [...new Set(mods.flatMap(m => instanceList(env, m).map(i => i.target)))]
483
499
  if (runTargets.some(t => !t.host || !t.user)) throw new Error('SSH 目标配置不完整')
484
500
 
485
501
  const rec = {
package/server/tools.js CHANGED
@@ -16,6 +16,7 @@ export function envSummaryForModel(e) {
16
16
  modules: (e.modules || []).map(m => ({
17
17
  id: m.id, name: m.name, kind: m.kind, enabled: m.enabled, buildCmd: m.buildCmd, sshId: m.sshId || '',
18
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 })),
19
20
  })),
20
21
  }
21
22
  }
@@ -34,7 +35,7 @@ export function registerTools(ctx, defineTool, ensure) {
34
35
  const ready = () => (ensure ? ensure() : Promise.resolve())
35
36
  ctx.tools.register(defineTool({
36
37
  name: 'deploy_list_envs',
37
- description: 'List deploy environments configured in the dsh-app-deploy plugin: SSH targets (passwords stripped), build modules with their build commands, artifact paths and publish targets. Use before deploy_run to pick env/module IDs.',
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.',
38
39
  parameters: {},
39
40
  output: {
40
41
  schema: {