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 +1 -1
- package/client.js +33 -3
- package/package.json +1 -1
- package/server/config.js +33 -0
- package/server/deployment.js +60 -44
- package/server/tools.js +2 -1
package/README.md
CHANGED
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/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' }
|
|
@@ -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.
|
|
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
|
|
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: {
|