dsh-ssh-manage 0.3.6 → 0.3.8
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.en.md +5 -4
- package/README.md +5 -4
- package/client.js +53 -4
- package/index.js +69 -3
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
|
|
23
23
|
`dsh-ssh-manage` is a Web plugin for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness). It turns the hosts in `~/.ssh/config` into manageable objects in the DSH sidebar:
|
|
24
24
|
|
|
25
|
-
- **Host management** — scan the config, groups, tags, favorites, notes, latency and host inspection;
|
|
25
|
+
- **Host management** — scan the config, groups, tags, favorites, notes, latency and host inspection; SSH aliases are globally unique, so use the repeatable Display name for same names across groups;
|
|
26
|
+
- **DSH Agent integration** — the host catalog is injected into the DSH system prompt so the Agent can use `ssh_manager` directly for exec, batch commands, inspection and SFTP on recorded servers;
|
|
26
27
|
- **Interactive terminal** — ssh2 allocates a PTY, xterm.js renders it, with colors, arrow keys, full-screen programs such as `vim`/`top`, and window resizing;
|
|
27
28
|
- **Backup and migration** — export/import hosts, groups, tags, notes, favorites and passwords from Settings; exports are re-encrypted with a passphrase (scrypt + AES-256-GCM) and imports merge by alias;
|
|
28
29
|
- **Day-to-day operations** — batch commands, SFTP browse/mkdir/rename/delete/upload/download, connection tests;
|
|
@@ -97,16 +98,16 @@ Creating a host requires alias, hostname and password. The password is not writt
|
|
|
97
98
|
|
|
98
99
|
### From npm (recommended)
|
|
99
100
|
|
|
100
|
-
npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.3.
|
|
101
|
+
npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.3.8`).
|
|
101
102
|
|
|
102
103
|
```text
|
|
103
|
-
plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.
|
|
104
|
+
plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.8
|
|
104
105
|
```
|
|
105
106
|
|
|
106
107
|
Or with the DSH CLI:
|
|
107
108
|
|
|
108
109
|
```bash
|
|
109
|
-
dsh plugin add dsh-ssh-manage@0.3.
|
|
110
|
+
dsh plugin add dsh-ssh-manage@0.3.8
|
|
110
111
|
```
|
|
111
112
|
|
|
112
113
|
After installation, refresh the Web page; the sidebar shows an "SSH Hosts" entry with id `dsh-ssh-manage`.
|
package/README.md
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
|
|
23
23
|
`dsh-ssh-manage` 是 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) 的 Web 插件。它把 `~/.ssh/config` 里的主机变成 DSH 侧边栏里可管理、可连接、可运维的对象:
|
|
24
24
|
|
|
25
|
-
- **主机管理**:扫描配置、分组、标签、收藏、备注、连接延迟、主机巡检;
|
|
25
|
+
- **主机管理**:扫描配置、分组、标签、收藏、备注、连接延迟、主机巡检;SSH 别名全局唯一,不同分组如需同名请使用可重复的“显示名称”;
|
|
26
|
+
- **DSH Agent 集成**:主机目录会注入 DSH 系统提示,Agent 可以直接用 `ssh_manager` 对已录入的服务器执行命令、批量命令、巡检和 SFTP 操作;
|
|
26
27
|
- **交互式终端**:ssh2 分配 PTY,xterm.js 渲染,支持彩色输出、方向键、`vim`/`top` 等全屏程序和窗口自适应;
|
|
27
28
|
- **备份迁移**:设置页可导出/导入主机、分组、标签、备注、收藏和密码;导出文件用自定义口令二次加密(scrypt + AES-256-GCM),导入按别名合并;
|
|
28
29
|
- **日常运维**:批量命令、SFTP 目录浏览 / 新建 / 重命名 / 删除 / 上传 / 下载、测试连接;
|
|
@@ -99,16 +100,16 @@
|
|
|
99
100
|
|
|
100
101
|
### 从 npm 安装(推荐)
|
|
101
102
|
|
|
102
|
-
npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.3.
|
|
103
|
+
npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.3.8`)。
|
|
103
104
|
|
|
104
105
|
```text
|
|
105
|
-
plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.
|
|
106
|
+
plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.8
|
|
106
107
|
```
|
|
107
108
|
|
|
108
109
|
或使用 DSH CLI:
|
|
109
110
|
|
|
110
111
|
```bash
|
|
111
|
-
dsh plugin add dsh-ssh-manage@0.3.
|
|
112
|
+
dsh plugin add dsh-ssh-manage@0.3.8
|
|
112
113
|
```
|
|
113
114
|
|
|
114
115
|
安装后刷新 Web 页面,左侧栏会出现「SSH 主机」入口,入口 id 为 `dsh-ssh-manage`。
|
package/client.js
CHANGED
|
@@ -126,7 +126,7 @@ window.__ModuleLoader__.load({
|
|
|
126
126
|
'editor.editTitle': ['编辑 SSH 主机', 'Edit SSH host'],
|
|
127
127
|
'editor.alias': ['别名', 'Alias'],
|
|
128
128
|
'editor.aliasPlaceholder': ['prod', 'prod'],
|
|
129
|
-
'editor.aliasHint': ['~/.ssh/config 中的 Host
|
|
129
|
+
'editor.aliasHint': ['~/.ssh/config 中的 Host 别名,必须全局唯一;不同分组要显示同名,请使用“显示名称”。', 'The Host alias in ~/.ssh/config must be globally unique. To show the same name in different groups, use Display name.'],
|
|
130
130
|
'editor.hostname': ['服务器地址', 'Hostname'],
|
|
131
131
|
'editor.hostnamePlaceholder': ['203.0.113.10 或 example.com', '203.0.113.10 or example.com'],
|
|
132
132
|
'editor.user': ['用户', 'User'],
|
|
@@ -141,6 +141,9 @@ window.__ModuleLoader__.load({
|
|
|
141
141
|
'editor.tagsPlaceholder': ['web, critical(逗号分隔)', 'web, critical (comma separated)'],
|
|
142
142
|
'editor.label': ['显示名称', 'Display name'],
|
|
143
143
|
'editor.labelPlaceholder': ['例如 生产 API', 'e.g. Production API'],
|
|
144
|
+
'editor.labelHint': ['显示名称可以重复,列表和终端标签优先显示它;SSH 别名仍需唯一。', 'Display names may repeat; lists and terminal tabs prefer it. The SSH alias must still be unique.'],
|
|
145
|
+
'editor.aliasExists': ['SSH 别名“{alias}”已被主机“{host}”使用(分组:{group})。SSH 要求别名全局唯一;如果只是想在另一个分组显示同名,请改用建议别名,并把“显示名称”设为 {alias}。', 'SSH alias "{alias}" is already used by "{host}" (group: {group}). SSH aliases must be globally unique. To show the same name in another group, use the suggested alias and set Display name to {alias}.'],
|
|
146
|
+
'editor.useSuggestedAlias': ['使用建议别名 {alias}', 'Use suggested alias {alias}'],
|
|
144
147
|
'editor.note': ['备注', 'Notes'],
|
|
145
148
|
'editor.notePlaceholder': ['给这台主机的说明,会显示在列表里', 'A short note shown in the list'],
|
|
146
149
|
'editor.passwordSection': ['服务器密码', 'Server password'],
|
|
@@ -4729,6 +4732,7 @@ window.__ModuleLoader__.load({
|
|
|
4729
4732
|
const [password, setPassword] = React.useState('')
|
|
4730
4733
|
const [passwordError, setPasswordError] = React.useState('')
|
|
4731
4734
|
const [formError, setFormError] = React.useState('')
|
|
4735
|
+
const [aliasSuggestion, setAliasSuggestion] = React.useState('')
|
|
4732
4736
|
const [groupMenuOpen, setGroupMenuOpen] = React.useState(false)
|
|
4733
4737
|
const groupMenuRef = React.useRef(null)
|
|
4734
4738
|
const groupOptions = (model.allGroups ?? []).filter((group) => group.key !== '').map((group) => group.key)
|
|
@@ -4798,6 +4802,28 @@ window.__ModuleLoader__.load({
|
|
|
4798
4802
|
setFormError('')
|
|
4799
4803
|
return
|
|
4800
4804
|
}
|
|
4805
|
+
const collision = hosts.find((host) =>
|
|
4806
|
+
host.host !== state.original
|
|
4807
|
+
&& (host.host === alias || (Array.isArray(host.aliases) && host.aliases.includes(alias))),
|
|
4808
|
+
)
|
|
4809
|
+
if (collision) {
|
|
4810
|
+
const groupPart = draft.group.trim().replace(/[^A-Za-z0-9_.@-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 20)
|
|
4811
|
+
let suggested = groupPart ? `${alias}-${groupPart}` : `${alias}-2`
|
|
4812
|
+
let counter = 2
|
|
4813
|
+
while (hosts.some((host) => host.host === suggested || (Array.isArray(host.aliases) && host.aliases.includes(suggested)))) {
|
|
4814
|
+
suggested = `${alias}-${counter}`
|
|
4815
|
+
counter += 1
|
|
4816
|
+
}
|
|
4817
|
+
suggested = suggested.slice(0, 64)
|
|
4818
|
+
setAliasSuggestion(suggested)
|
|
4819
|
+
setFormError(t('editor.aliasExists', {
|
|
4820
|
+
alias,
|
|
4821
|
+
host: hostName(collision),
|
|
4822
|
+
group: collision.group || t('tree.ungrouped'),
|
|
4823
|
+
}))
|
|
4824
|
+
return
|
|
4825
|
+
}
|
|
4826
|
+
setAliasSuggestion('')
|
|
4801
4827
|
setPasswordError('')
|
|
4802
4828
|
setFormError('')
|
|
4803
4829
|
const saved = await saveEditor({
|
|
@@ -4860,13 +4886,36 @@ window.__ModuleLoader__.load({
|
|
|
4860
4886
|
h(
|
|
4861
4887
|
'div',
|
|
4862
4888
|
{ className: 'ssh-formSection' },
|
|
4863
|
-
formError
|
|
4889
|
+
formError
|
|
4890
|
+
? h(
|
|
4891
|
+
'div',
|
|
4892
|
+
{ className: 'ssh-formError' },
|
|
4893
|
+
h(IconAlert, { size: 13 }),
|
|
4894
|
+
h('span', null, formError),
|
|
4895
|
+
aliasSuggestion
|
|
4896
|
+
? h(
|
|
4897
|
+
Button,
|
|
4898
|
+
{
|
|
4899
|
+
size: 'sm',
|
|
4900
|
+
variant: 'ghost',
|
|
4901
|
+
onClick: () => {
|
|
4902
|
+
if (!draft.label.trim()) update('label', alias)
|
|
4903
|
+
update('host', aliasSuggestion)
|
|
4904
|
+
setAliasSuggestion('')
|
|
4905
|
+
setFormError('')
|
|
4906
|
+
},
|
|
4907
|
+
},
|
|
4908
|
+
t('editor.useSuggestedAlias', { alias: aliasSuggestion }),
|
|
4909
|
+
)
|
|
4910
|
+
: null,
|
|
4911
|
+
)
|
|
4912
|
+
: null,
|
|
4864
4913
|
h(
|
|
4865
4914
|
'div',
|
|
4866
4915
|
{ className: 'ssh-formGrid' },
|
|
4867
4916
|
h(
|
|
4868
4917
|
Field,
|
|
4869
|
-
{ label: t('editor.alias') },
|
|
4918
|
+
{ label: t('editor.alias'), hint: t('editor.aliasHint') },
|
|
4870
4919
|
h(TextInput, {
|
|
4871
4920
|
value: draft.host,
|
|
4872
4921
|
onChange: (value) => update('host', value),
|
|
@@ -4964,7 +5013,7 @@ window.__ModuleLoader__.load({
|
|
|
4964
5013
|
),
|
|
4965
5014
|
h(
|
|
4966
5015
|
Field,
|
|
4967
|
-
{ label: t('editor.label') },
|
|
5016
|
+
{ label: t('editor.label'), hint: t('editor.labelHint') },
|
|
4968
5017
|
h(TextInput, {
|
|
4969
5018
|
value: draft.label,
|
|
4970
5019
|
onChange: (value) => update('label', value),
|
package/index.js
CHANGED
|
@@ -78,6 +78,54 @@ function errorMessage(error) {
|
|
|
78
78
|
return error instanceof Error ? error.message : String(error)
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
let sshHostCatalogText = ''
|
|
82
|
+
let sshHostCatalogRefresh = null
|
|
83
|
+
|
|
84
|
+
function sshCatalogLine(host, metadata, passwordSet) {
|
|
85
|
+
const user = host.user ? `${host.user}@` : ''
|
|
86
|
+
const address = `${host.hostname || host.host}${host.port ? `:${host.port}` : ''}`
|
|
87
|
+
const group = String(metadata?.group || '').replace(/[\r\n]+/g, ' ').trim() || 'ungrouped'
|
|
88
|
+
const label = String(metadata?.label || '').replace(/[\r\n]+/g, ' ').trim()
|
|
89
|
+
const tags = Array.isArray(metadata?.tags) ? metadata.tags.map((tag) => String(tag).replace(/[\r\n]+/g, ' ')).filter(Boolean) : []
|
|
90
|
+
const parts = [
|
|
91
|
+
`alias=${host.host}`,
|
|
92
|
+
`target=${user}${address}`,
|
|
93
|
+
`group=${group}`,
|
|
94
|
+
]
|
|
95
|
+
if (label && label !== host.host) parts.push(`display=${label}`)
|
|
96
|
+
if (tags.length) parts.push(`tags=${tags.slice(0, 4).join(',')}`)
|
|
97
|
+
parts.push(`password=${passwordSet.has(host.host) ? 'stored' : 'missing'}`)
|
|
98
|
+
return `- ${parts.join(' | ')}`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Refresh the host catalog injected into the DSH system prompt. */
|
|
102
|
+
export async function refreshSshHostCatalog(paths) {
|
|
103
|
+
if (sshHostCatalogRefresh) return sshHostCatalogRefresh
|
|
104
|
+
sshHostCatalogRefresh = (async () => {
|
|
105
|
+
try {
|
|
106
|
+
const [{ hosts }, metadata, passwords] = await Promise.all([
|
|
107
|
+
listSshHosts(paths),
|
|
108
|
+
loadMetadata(paths).catch(() => ({ entries: {} })),
|
|
109
|
+
listPasswordAliases(paths).catch(() => []),
|
|
110
|
+
])
|
|
111
|
+
const passwordSet = new Set(passwords)
|
|
112
|
+
const lines = hosts.slice(0, 40).map((host) => sshCatalogLine(host, metadata.entries[host.host], passwordSet))
|
|
113
|
+
if (hosts.length > 40) lines.push(`- ... ${hosts.length - 40} more hosts; call list_hosts for the full list`)
|
|
114
|
+
sshHostCatalogText = [
|
|
115
|
+
'SSH hosts managed by the dsh-ssh-manage plugin. When the user asks to inspect, run commands on, upload to or download from one of these servers, call the ssh_manager tool directly with the exact alias.',
|
|
116
|
+
'Use action=exec with {host, command} for one command, batch_exec with {hosts, command} for several, and sftp_* actions for files. Use test_connection or inspect_host for status and system facts. list_hosts returns the full catalog.',
|
|
117
|
+
'Passwords are never returned by the plugin; never ask the user to paste a password into the conversation.',
|
|
118
|
+
lines.length ? lines.join('\n') : '- No SSH hosts are configured yet; the user can add them in the dsh-ssh-manage sidebar panel.',
|
|
119
|
+
].join('\n').slice(0, 6000)
|
|
120
|
+
} catch {
|
|
121
|
+
sshHostCatalogText = 'SSH host catalog is temporarily unavailable; call ssh_manager action=list_hosts to read it.'
|
|
122
|
+
} finally {
|
|
123
|
+
sshHostCatalogRefresh = null
|
|
124
|
+
}
|
|
125
|
+
})()
|
|
126
|
+
return sshHostCatalogRefresh
|
|
127
|
+
}
|
|
128
|
+
|
|
81
129
|
async function state(paths) {
|
|
82
130
|
const [current, metadataResult, passwordsResult] = await Promise.all([
|
|
83
131
|
listSshHosts(paths),
|
|
@@ -93,6 +141,7 @@ async function state(paths) {
|
|
|
93
141
|
else passwords = passwordsResult.value
|
|
94
142
|
const passwordSet = new Set(passwords)
|
|
95
143
|
const stateDir = storagePaths(paths).stateDir
|
|
144
|
+
void refreshSshHostCatalog(paths)
|
|
96
145
|
return {
|
|
97
146
|
...current,
|
|
98
147
|
stateDir,
|
|
@@ -253,7 +302,7 @@ export async function runAction(paths, action, params = {}) {
|
|
|
253
302
|
function managerTool(paths) {
|
|
254
303
|
return {
|
|
255
304
|
name: 'ssh_manager',
|
|
256
|
-
description: 'Manage SSH hosts, encrypted local passwords, remote commands and SFTP operations. Passwords are never returned.',
|
|
305
|
+
description: 'Manage SSH hosts, encrypted local passwords, remote commands and SFTP operations. Hosts entered in the dsh-ssh-manage panel are listed in the system prompt; when the user asks to operate on a server, call this tool directly with its alias. Use action=exec for one command, batch_exec for several hosts, sftp_* for files. Passwords are never returned.',
|
|
257
306
|
parameters: {
|
|
258
307
|
type: 'object',
|
|
259
308
|
additionalProperties: false,
|
|
@@ -262,7 +311,7 @@ function managerTool(paths) {
|
|
|
262
311
|
action: { type: 'string', enum: ACTIONS, description: 'The operation to run.' },
|
|
263
312
|
host: {
|
|
264
313
|
oneOf: [{ type: 'string' }, { type: 'object' }],
|
|
265
|
-
description: 'Host alias as a string for every action except save_host, which takes a host object: { host, hostname, user, port, identityFile, proxyJump, forwardAgent }.',
|
|
314
|
+
description: 'Host alias as a string (see the SSH host catalog in the system prompt) for every action except save_host, which takes a host object: { host, hostname, user, port, identityFile, proxyJump, forwardAgent }.',
|
|
266
315
|
},
|
|
267
316
|
originalHost: { type: 'string', description: 'Existing alias when renaming a host.' },
|
|
268
317
|
password: { type: 'string', description: 'Password to save for a host. It is write-only.' },
|
|
@@ -830,7 +879,7 @@ function registerAssetRoutes(ctx) {
|
|
|
830
879
|
}
|
|
831
880
|
}
|
|
832
881
|
|
|
833
|
-
export const __test = { resolveSshPaths, listSshHosts, saveSshHost, deleteSshHost, runAction, managerTool, MAX_BODY_BYTES, PTY_ROUTE, PTY_EVENTS_ROUTE, PTY_OPEN_ROUTE, PTY_ATTACH_ROUTE, PTY_INPUT_ROUTE, PTY_RESIZE_ROUTE, PTY_CLOSE_ROUTE, PTY_CLEAR_ROUTE, PTY_TOUCH_ROUTE, PTY_IDLE_MS, sweepIdlePtySessions, MAX_PTY_SESSIONS, PTY_BUFFER_BYTES, ASSETS, handlePtyUpgrade, ptyTextSessions }
|
|
882
|
+
export const __test = { resolveSshPaths, listSshHosts, saveSshHost, deleteSshHost, runAction, managerTool, refreshSshHostCatalog, getSshHostCatalog: () => sshHostCatalogText, MAX_BODY_BYTES, PTY_ROUTE, PTY_EVENTS_ROUTE, PTY_OPEN_ROUTE, PTY_ATTACH_ROUTE, PTY_INPUT_ROUTE, PTY_RESIZE_ROUTE, PTY_CLOSE_ROUTE, PTY_CLEAR_ROUTE, PTY_TOUCH_ROUTE, PTY_IDLE_MS, sweepIdlePtySessions, MAX_PTY_SESSIONS, PTY_BUFFER_BYTES, ASSETS, handlePtyUpgrade, ptyTextSessions }
|
|
834
883
|
|
|
835
884
|
export const inject = ['webServer']
|
|
836
885
|
|
|
@@ -839,7 +888,12 @@ export function apply(ctx) {
|
|
|
839
888
|
throw new Error('dsh-ssh-manage requires DeepSeek Harness >=0.2.1-rc.3: ctx.webServer.register is unavailable.')
|
|
840
889
|
}
|
|
841
890
|
const paths = resolveSshPaths(process.env)
|
|
891
|
+
void refreshSshHostCatalog(paths)
|
|
842
892
|
const runtime = createPtyRuntime()
|
|
893
|
+
const catalogTimer = setInterval(() => {
|
|
894
|
+
if (runtime.active) void refreshSshHostCatalog(paths)
|
|
895
|
+
}, 30_000)
|
|
896
|
+
catalogTimer.unref?.()
|
|
843
897
|
const runtimeIdleTimer = setInterval(() => {
|
|
844
898
|
if (!runtime.active) return
|
|
845
899
|
// Browser heartbeats (/pty/touch) keep attached sessions alive, so a session
|
|
@@ -934,6 +988,7 @@ export function apply(ctx) {
|
|
|
934
988
|
}
|
|
935
989
|
runtime.upgrades.clear()
|
|
936
990
|
clearInterval(runtimeIdleTimer)
|
|
991
|
+
clearInterval(catalogTimer)
|
|
937
992
|
for (const session of ptySessions) {
|
|
938
993
|
try { session.close() } catch {}
|
|
939
994
|
}
|
|
@@ -950,4 +1005,15 @@ export function apply(ctx) {
|
|
|
950
1005
|
ctx.inject(['tools'], (child) => {
|
|
951
1006
|
child.effect(() => child.tools.register(managerTool(paths)), 'dsh-ssh-manage: agent tool')
|
|
952
1007
|
})
|
|
1008
|
+
ctx.inject(['systemPrompt'], (child) => {
|
|
1009
|
+
if (!child.systemPrompt || typeof child.systemPrompt.context !== 'function') return
|
|
1010
|
+
child.effect(
|
|
1011
|
+
() => child.systemPrompt.context({
|
|
1012
|
+
name: 'dsh-ssh-manage-hosts',
|
|
1013
|
+
order: 60,
|
|
1014
|
+
text: () => sshHostCatalogText,
|
|
1015
|
+
}),
|
|
1016
|
+
'dsh-ssh-manage: host catalog prompt context',
|
|
1017
|
+
)
|
|
1018
|
+
})
|
|
953
1019
|
}
|