dsh-ssh-manage 0.3.1 → 0.3.2
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 +8 -7
- package/README.md +8 -7
- package/client.js +208 -16
- package/index.js +22 -2
- package/lib/ssh-client.js +87 -2
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
- **Host management** — scan the config, groups, tags, favorites, notes, latency and host inspection;
|
|
26
26
|
- **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
|
-
- **Day-to-day operations** — batch commands, SFTP browse/upload/download, connection tests;
|
|
27
|
+
- **Day-to-day operations** — batch commands, SFTP browse/mkdir/rename/delete/upload/download, connection tests;
|
|
28
28
|
- **Credentials** — one AES-256-GCM encrypted password per host, never written to `~/.ssh/config` and never returned to the browser;
|
|
29
29
|
- **Agent tool** — `ssh_manager` exposes the same capabilities to the model.
|
|
30
30
|
|
|
@@ -53,7 +53,7 @@ No more jumping between a terminal and DSH, and no need to remember where each h
|
|
|
53
53
|
### Batch commands and files
|
|
54
54
|
|
|
55
55
|
- **Batch commands** — select hosts and run one command across them, with per-host stdout/stderr/exit code.
|
|
56
|
-
- **SFTP** — browse remote directories
|
|
56
|
+
- **SFTP** — browse remote directories from the host drawer, create folders, rename, delete files/empty directories, upload and download; the last directory per host is remembered.
|
|
57
57
|
- **Connection test** — measures latency and writes it back to host metadata.
|
|
58
58
|
- **Host inspection** — distro, CPU cores, memory and disk capacity.
|
|
59
59
|
|
|
@@ -68,7 +68,7 @@ No more jumping between a terminal and DSH, and no need to remember where each h
|
|
|
68
68
|
|
|
69
69
|
### Agent tool
|
|
70
70
|
|
|
71
|
-
`ssh_manager` exposes: `overview`, `list_hosts`, `save_host`, `delete_host`, `set_metadata`, `save_password`, `delete_password`, `test_connection`, `inspect_host`, `exec`, `batch_exec`, `sftp_list`, `sftp_upload`, `sftp_download`.
|
|
71
|
+
`ssh_manager` exposes: `overview`, `list_hosts`, `save_host`, `delete_host`, `set_metadata`, `save_password`, `delete_password`, `test_connection`, `inspect_host`, `exec`, `batch_exec`, `sftp_list`, `sftp_upload`, `sftp_download`, `sftp_mkdir`, `sftp_rename`, `sftp_delete`.
|
|
72
72
|
|
|
73
73
|
The password parameter is write-only; no response contains a plaintext password.
|
|
74
74
|
|
|
@@ -96,16 +96,16 @@ Creating a host requires alias, hostname and password. The password is not writt
|
|
|
96
96
|
|
|
97
97
|
### From npm (recommended)
|
|
98
98
|
|
|
99
|
-
npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.3.
|
|
99
|
+
npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.3.2`).
|
|
100
100
|
|
|
101
101
|
```text
|
|
102
|
-
plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.
|
|
102
|
+
plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.2
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
Or with the DSH CLI:
|
|
106
106
|
|
|
107
107
|
```bash
|
|
108
|
-
dsh plugin add dsh-ssh-manage@0.3.
|
|
108
|
+
dsh plugin add dsh-ssh-manage@0.3.2
|
|
109
109
|
```
|
|
110
110
|
|
|
111
111
|
After installation, refresh the Web page; the sidebar shows an "SSH Hosts" entry with id `dsh-ssh-manage`.
|
|
@@ -137,7 +137,7 @@ and make sure the package is resolvable by the profile (for example `link:` to t
|
|
|
137
137
|
|
|
138
138
|
| | Requirement |
|
|
139
139
|
| --- | --- |
|
|
140
|
-
| **DSH** | **`>=0.2.1-rc.3 <0.3.
|
|
140
|
+
| **DSH** | **`>=0.2.1-rc.3 <0.3.0`** (validated on DSH Desktop `0.2.1-rc.3`) |
|
|
141
141
|
| **Node.js** | `>=20` (follows the DSH runtime) |
|
|
142
142
|
| **Platform** | Validated on macOS; the test suite only uses Node built-ins and runs cross-platform |
|
|
143
143
|
| **Network** | Reachable SSH port (22 by default, or the configured port) |
|
|
@@ -221,6 +221,7 @@ The terminal uses vendored xterm.js 6.0.0 (MIT, see `assets/LICENSE.xterm`), @xt
|
|
|
221
221
|
- **Config editing scope**: the editor only changes `Host`, `HostName`, `User`, `Port` and plugin metadata; maintain other SSH directives in `~/.ssh/config`.
|
|
222
222
|
- **Batch commands** run one command; they are not an interactive terminal. Per-host output is capped at 1 MB and marked `truncated` when exceeded.
|
|
223
223
|
- **File size**: SFTP files are capped at 10 MB.
|
|
224
|
+
- **SFTP rename/delete**: rename refuses to overwrite an existing destination by default (`sftp_rename` can pass `overwrite: true` explicitly); deleting a directory requires it to be empty, and root paths, `.`/`..` and drive roots are refused; symlinks are removed as links, not followed.
|
|
224
225
|
- **xterm assets**: the first terminal open loads about 500 KB of xterm.js basics; the search addon loads on demand, about 556 KiB total from the same origin (no CDN).
|
|
225
226
|
|
|
226
227
|
## Contributing
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
- **主机管理**:扫描配置、分组、标签、收藏、备注、连接延迟、主机巡检;
|
|
26
26
|
- **交互式终端**:ssh2 分配 PTY,xterm.js 渲染,支持彩色输出、方向键、`vim`/`top` 等全屏程序和窗口自适应;
|
|
27
|
-
- **日常运维**:批量命令、SFTP 目录浏览 / 上传 / 下载、测试连接;
|
|
27
|
+
- **日常运维**:批量命令、SFTP 目录浏览 / 新建 / 重命名 / 删除 / 上传 / 下载、测试连接;
|
|
28
28
|
- **凭据管理**:每台主机保存一份 AES-256-GCM 加密密码,不写进 `~/.ssh/config`,也不返回给浏览器;
|
|
29
29
|
- **Agent 工具**:`ssh_manager` 把同一套能力开放给模型。
|
|
30
30
|
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
### 批量命令与文件
|
|
54
54
|
|
|
55
55
|
- **批量命令**:勾选多台主机,一次执行同一条命令,按主机汇总 stdout / stderr / 退出码。
|
|
56
|
-
- **SFTP
|
|
56
|
+
- **SFTP**:在主机抽屉里浏览远程目录,新建文件夹、重命名、删除文件/空目录、上传、下载;会记住每台主机上次所在目录。
|
|
57
57
|
- **测试连接**:记录连接延迟,结果写回主机信息。
|
|
58
58
|
- **主机巡检**:读取发行版、CPU 核心数、内存、磁盘容量。
|
|
59
59
|
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
|
|
71
71
|
`ssh_manager` 工具开放以下动作,和页面走同一套 Host 逻辑:
|
|
72
72
|
|
|
73
|
-
`overview`、`list_hosts`、`save_host`、`delete_host`、`set_metadata`、`save_password`、`delete_password`、`test_connection`、`inspect_host`、`exec`、`batch_exec`、`sftp_list`、`sftp_upload`、`sftp_download`。
|
|
73
|
+
`overview`、`list_hosts`、`save_host`、`delete_host`、`set_metadata`、`save_password`、`delete_password`、`test_connection`、`inspect_host`、`exec`、`batch_exec`、`sftp_list`、`sftp_upload`、`sftp_download`、`sftp_mkdir`、`sftp_rename`、`sftp_delete`。
|
|
74
74
|
|
|
75
75
|
密码字段是 write-only,任何返回值都不包含密码明文。
|
|
76
76
|
|
|
@@ -98,16 +98,16 @@
|
|
|
98
98
|
|
|
99
99
|
### 从 npm 安装(推荐)
|
|
100
100
|
|
|
101
|
-
npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.3.
|
|
101
|
+
npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.3.2`)。
|
|
102
102
|
|
|
103
103
|
```text
|
|
104
|
-
plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.
|
|
104
|
+
plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.2
|
|
105
105
|
```
|
|
106
106
|
|
|
107
107
|
或使用 DSH CLI:
|
|
108
108
|
|
|
109
109
|
```bash
|
|
110
|
-
dsh plugin add dsh-ssh-manage@0.3.
|
|
110
|
+
dsh plugin add dsh-ssh-manage@0.3.2
|
|
111
111
|
```
|
|
112
112
|
|
|
113
113
|
安装后刷新 Web 页面,左侧栏会出现「SSH 主机」入口,入口 id 为 `dsh-ssh-manage`。
|
|
@@ -139,7 +139,7 @@ plugin_manager action=install_bundle target=<仓库绝对路径>/dsh-ssh-manage
|
|
|
139
139
|
|
|
140
140
|
| | 要求 |
|
|
141
141
|
| --- | --- |
|
|
142
|
-
| **DSH** | **`>=0.2.1-rc.3 <0.3.
|
|
142
|
+
| **DSH** | **`>=0.2.1-rc.3 <0.3.0`**(实机验证版本:DSH Desktop `0.2.1-rc.3`) |
|
|
143
143
|
| **Node.js** | `>=20`(跟随 DSH 内置运行时) |
|
|
144
144
|
| **平台** | 实机验证 macOS;测试套件只依赖 Node 内置模块,可跨平台运行 |
|
|
145
145
|
| **网络** | 需要能访问目标 SSH 主机的 22 端口(或配置的端口) |
|
|
@@ -224,6 +224,7 @@ npm test
|
|
|
224
224
|
- **配置编辑范围**:编辑器只修改 `Host`、`HostName`、`User`、`Port` 与插件元数据;其它 SSH 指令请在 `~/.ssh/config` 中维护。
|
|
225
225
|
- **批量命令**:一次执行一条命令,不等同于交互式终端;单台输出上限 1 MB,超出会标记 `truncated`。
|
|
226
226
|
- **文件大小**:SFTP 单文件上限 10 MB。
|
|
227
|
+
- **SFTP 重命名/删除**:重命名默认拒绝覆盖已存在的目标(`sftp_rename` 工具可显式传 `overwrite: true`);删除目录要求目录为空,根路径、`.`/`..` 和盘符根会被拒绝;符号链接按链接本身删除,不会跟随到目标。
|
|
227
228
|
- **xterm 资源**:首次打开会加载约 500 KB 的 xterm.js 基础资源;搜索组件按需加载,总计约 556 KiB(本地同源,不访问 CDN)。
|
|
228
229
|
|
|
229
230
|
## 参与贡献
|
package/client.js
CHANGED
|
@@ -182,6 +182,19 @@ window.__ModuleLoader__.load({
|
|
|
182
182
|
'drawer.files.path': ['远程路径', 'Remote path'],
|
|
183
183
|
'drawer.files.up': ['上级目录', 'Parent directory'],
|
|
184
184
|
'drawer.files.empty': ['目录暂无内容。', 'This directory is empty.'],
|
|
185
|
+
'drawer.files.mkdir': ['新建文件夹', 'New folder'],
|
|
186
|
+
'drawer.files.mkdirTitle': ['新建文件夹', 'New folder'],
|
|
187
|
+
'drawer.files.rename': ['重命名', 'Rename'],
|
|
188
|
+
'drawer.files.renameTitle': ['重命名', 'Rename'],
|
|
189
|
+
'drawer.files.delete': ['删除', 'Delete'],
|
|
190
|
+
'drawer.files.deleteTitle': ['删除', 'Delete'],
|
|
191
|
+
'drawer.files.deleteConfirm': ['确定删除“{name}”?删除目录时要求目录为空。', 'Delete "{name}"? Directories must be empty.'],
|
|
192
|
+
'drawer.files.name': ['名称', 'Name'],
|
|
193
|
+
'drawer.files.invalidName': ['名称不能为空,也不能包含 / 或 \\。', 'Name must be non-empty and cannot contain / or \\.'],
|
|
194
|
+
'drawer.files.created': ['文件夹已创建', 'Folder created'],
|
|
195
|
+
'drawer.files.renamed': ['已重命名', 'Renamed'],
|
|
196
|
+
'drawer.files.deleted': ['已删除', 'Deleted'],
|
|
197
|
+
'common.create': ['新建', 'Create'],
|
|
185
198
|
'drawer.files.dir': ['目录', 'dir'],
|
|
186
199
|
'drawer.files.uploading': ['上传中…', 'Uploading…'],
|
|
187
200
|
'drawer.details.connection': ['连接信息', 'Connection'],
|
|
@@ -793,7 +806,7 @@ window.__ModuleLoader__.load({
|
|
|
793
806
|
return h('span', { className: 'ssh-spinner', style: { width: size, height: size } })
|
|
794
807
|
}
|
|
795
808
|
|
|
796
|
-
function Button({ children, variant, size, onClick, disabled, title, type = 'button', className, ariaPressed }) {
|
|
809
|
+
function Button({ children, variant, size, onClick, disabled, title, type = 'button', className, ariaPressed, autoFocus }) {
|
|
797
810
|
return h(
|
|
798
811
|
'button',
|
|
799
812
|
{
|
|
@@ -803,6 +816,7 @@ window.__ModuleLoader__.load({
|
|
|
803
816
|
disabled,
|
|
804
817
|
title,
|
|
805
818
|
'aria-pressed': ariaPressed,
|
|
819
|
+
autoFocus,
|
|
806
820
|
},
|
|
807
821
|
children,
|
|
808
822
|
)
|
|
@@ -4088,25 +4102,50 @@ window.__ModuleLoader__.load({
|
|
|
4088
4102
|
)
|
|
4089
4103
|
}
|
|
4090
4104
|
|
|
4105
|
+
const SFTP_PATH_PREFIX = 'dsh-ssh-manage:sftp-path:'
|
|
4106
|
+
|
|
4107
|
+
function readSftpPath(alias) {
|
|
4108
|
+
try {
|
|
4109
|
+
const stored = window.localStorage.getItem(SFTP_PATH_PREFIX + alias)
|
|
4110
|
+
return stored && stored.trim() ? stored : '.'
|
|
4111
|
+
} catch {
|
|
4112
|
+
return '.'
|
|
4113
|
+
}
|
|
4114
|
+
}
|
|
4115
|
+
|
|
4116
|
+
function saveSftpPath(alias, target) {
|
|
4117
|
+
try {
|
|
4118
|
+
window.localStorage.setItem(SFTP_PATH_PREFIX + alias, target)
|
|
4119
|
+
} catch {}
|
|
4120
|
+
}
|
|
4121
|
+
|
|
4091
4122
|
function FilesView({ model, host }) {
|
|
4092
|
-
const { t, notify
|
|
4093
|
-
const [path, setPath] = React.useState(
|
|
4123
|
+
const { t, notify } = model
|
|
4124
|
+
const [path, setPath] = React.useState(() => readSftpPath(host.host))
|
|
4094
4125
|
const [entries, setEntries] = React.useState([])
|
|
4095
4126
|
const [loading, setLoading] = React.useState(true)
|
|
4096
4127
|
const [error, setError] = React.useState('')
|
|
4097
4128
|
const [uploading, setUploading] = React.useState(false)
|
|
4129
|
+
const [dialog, setDialog] = React.useState(null)
|
|
4130
|
+
const [dialogValue, setDialogValue] = React.useState('')
|
|
4131
|
+
const [dialogBusy, setDialogBusy] = React.useState(false)
|
|
4132
|
+
const dialogBusyRef = React.useRef(false)
|
|
4098
4133
|
const fileRef = React.useRef(null)
|
|
4099
4134
|
|
|
4100
4135
|
const loadDirectory = React.useCallback(
|
|
4101
|
-
async (target) => {
|
|
4136
|
+
async (target, options = {}) => {
|
|
4137
|
+
const normalized = String(target ?? '').trim() || '.'
|
|
4102
4138
|
setLoading(true)
|
|
4103
4139
|
setError('')
|
|
4104
4140
|
try {
|
|
4105
|
-
const value = await callAction('sftp/list', { host: host.host, path:
|
|
4141
|
+
const value = await callAction('sftp/list', { host: host.host, path: normalized })
|
|
4106
4142
|
setEntries(value.entries ?? [])
|
|
4107
|
-
setPath(
|
|
4143
|
+
setPath(normalized)
|
|
4144
|
+
if (options.remember !== false) saveSftpPath(host.host, normalized)
|
|
4145
|
+
return true
|
|
4108
4146
|
} catch (failure) {
|
|
4109
4147
|
setError(errorMessage(failure))
|
|
4148
|
+
return false
|
|
4110
4149
|
} finally {
|
|
4111
4150
|
setLoading(false)
|
|
4112
4151
|
}
|
|
@@ -4115,8 +4154,72 @@ window.__ModuleLoader__.load({
|
|
|
4115
4154
|
)
|
|
4116
4155
|
|
|
4117
4156
|
React.useEffect(() => {
|
|
4118
|
-
|
|
4119
|
-
|
|
4157
|
+
let cancelled = false
|
|
4158
|
+
void (async () => {
|
|
4159
|
+
const initial = readSftpPath(host.host)
|
|
4160
|
+
const ok = await loadDirectory(initial)
|
|
4161
|
+
if (!ok && initial !== '.' && !cancelled) await loadDirectory('.', { remember: false })
|
|
4162
|
+
})()
|
|
4163
|
+
return () => { cancelled = true }
|
|
4164
|
+
}, [host.host, loadDirectory])
|
|
4165
|
+
|
|
4166
|
+
React.useEffect(() => {
|
|
4167
|
+
if (!dialog) return undefined
|
|
4168
|
+
const onKey = (event) => {
|
|
4169
|
+
if (event.key === 'Escape' && !dialogBusy) {
|
|
4170
|
+
event.preventDefault()
|
|
4171
|
+
event.stopPropagation()
|
|
4172
|
+
setDialog(null)
|
|
4173
|
+
}
|
|
4174
|
+
}
|
|
4175
|
+
document.addEventListener('keydown', onKey, true)
|
|
4176
|
+
return () => document.removeEventListener('keydown', onKey, true)
|
|
4177
|
+
}, [dialog, dialogBusy])
|
|
4178
|
+
|
|
4179
|
+
const openDialog = (mode, entry = null) => {
|
|
4180
|
+
setDialog({ mode, entry })
|
|
4181
|
+
setDialogValue(mode === 'rename' && entry ? entry.name : '')
|
|
4182
|
+
}
|
|
4183
|
+
|
|
4184
|
+
async function submitDialog() {
|
|
4185
|
+
if (!dialog || dialogBusyRef.current) return
|
|
4186
|
+
dialogBusyRef.current = true
|
|
4187
|
+
const name = dialogValue.trim()
|
|
4188
|
+
if (dialog.mode !== 'delete') {
|
|
4189
|
+
if (!name || name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
|
|
4190
|
+
notify(t('drawer.files.invalidName'), 'error')
|
|
4191
|
+
return
|
|
4192
|
+
}
|
|
4193
|
+
}
|
|
4194
|
+
setDialogBusy(true)
|
|
4195
|
+
try {
|
|
4196
|
+
if (dialog.mode === 'mkdir') {
|
|
4197
|
+
await callAction('sftp/mkdir', { host: host.host, path: remoteJoin(path, name) })
|
|
4198
|
+
notify(t('drawer.files.created'), 'success')
|
|
4199
|
+
} else if (dialog.mode === 'rename') {
|
|
4200
|
+
if (name === dialog.entry.name) {
|
|
4201
|
+
setDialog(null)
|
|
4202
|
+
return
|
|
4203
|
+
}
|
|
4204
|
+
await callAction('sftp/rename', {
|
|
4205
|
+
host: host.host,
|
|
4206
|
+
path: remoteJoin(path, dialog.entry.name),
|
|
4207
|
+
newPath: remoteJoin(path, name),
|
|
4208
|
+
})
|
|
4209
|
+
notify(t('drawer.files.renamed'), 'success')
|
|
4210
|
+
} else if (dialog.mode === 'delete') {
|
|
4211
|
+
await callAction('sftp/delete', { host: host.host, path: remoteJoin(path, dialog.entry.name) })
|
|
4212
|
+
notify(t('drawer.files.deleted'), 'success')
|
|
4213
|
+
}
|
|
4214
|
+
setDialog(null)
|
|
4215
|
+
await loadDirectory(path)
|
|
4216
|
+
} catch (failure) {
|
|
4217
|
+
notify(t('state.error', { message: errorMessage(failure) }), 'error')
|
|
4218
|
+
} finally {
|
|
4219
|
+
dialogBusyRef.current = false
|
|
4220
|
+
setDialogBusy(false)
|
|
4221
|
+
}
|
|
4222
|
+
}
|
|
4120
4223
|
|
|
4121
4224
|
async function downloadEntry(entry) {
|
|
4122
4225
|
const remote = remoteJoin(path, entry.name)
|
|
@@ -4183,6 +4286,12 @@ window.__ModuleLoader__.load({
|
|
|
4183
4286
|
{ title: t('common.refresh'), onClick: () => void loadDirectory(path), disabled: loading },
|
|
4184
4287
|
loading ? h(Spinner, { size: 13 }) : h(IconRefresh, { size: 15 }),
|
|
4185
4288
|
),
|
|
4289
|
+
h(
|
|
4290
|
+
Button,
|
|
4291
|
+
{ size: 'sm', variant: 'ghost', onClick: () => openDialog('mkdir'), disabled: loading || uploading },
|
|
4292
|
+
h(IconPlus, { size: 13 }),
|
|
4293
|
+
t('drawer.files.mkdir'),
|
|
4294
|
+
),
|
|
4186
4295
|
h(
|
|
4187
4296
|
Button,
|
|
4188
4297
|
{ size: 'sm', onClick: () => fileRef.current && fileRef.current.click(), disabled: uploading },
|
|
@@ -4194,6 +4303,69 @@ window.__ModuleLoader__.load({
|
|
|
4194
4303
|
error
|
|
4195
4304
|
? h('div', { className: 'ssh-fileError' }, h(IconAlert, { size: 13 }), h('span', null, error))
|
|
4196
4305
|
: null,
|
|
4306
|
+
dialog
|
|
4307
|
+
? h(
|
|
4308
|
+
'div',
|
|
4309
|
+
{
|
|
4310
|
+
className: 'ssh-fileDialogScrim',
|
|
4311
|
+
onMouseDown: () => {
|
|
4312
|
+
if (!dialogBusy) setDialog(null)
|
|
4313
|
+
},
|
|
4314
|
+
},
|
|
4315
|
+
h(
|
|
4316
|
+
'div',
|
|
4317
|
+
{
|
|
4318
|
+
className: 'ssh-fileDialog',
|
|
4319
|
+
role: 'dialog',
|
|
4320
|
+
'aria-modal': 'true',
|
|
4321
|
+
'aria-label': dialog.mode === 'mkdir'
|
|
4322
|
+
? t('drawer.files.mkdirTitle')
|
|
4323
|
+
: dialog.mode === 'rename'
|
|
4324
|
+
? t('drawer.files.renameTitle')
|
|
4325
|
+
: t('drawer.files.deleteTitle'),
|
|
4326
|
+
onMouseDown: (event) => event.stopPropagation(),
|
|
4327
|
+
},
|
|
4328
|
+
h(
|
|
4329
|
+
'div',
|
|
4330
|
+
{ className: 'ssh-fileDialogTitle' },
|
|
4331
|
+
dialog.mode === 'mkdir'
|
|
4332
|
+
? t('drawer.files.mkdirTitle')
|
|
4333
|
+
: dialog.mode === 'rename'
|
|
4334
|
+
? t('drawer.files.renameTitle')
|
|
4335
|
+
: t('drawer.files.deleteTitle'),
|
|
4336
|
+
),
|
|
4337
|
+
dialog.mode === 'delete'
|
|
4338
|
+
? h('div', { className: 'ssh-fileDialogBody' }, t('drawer.files.deleteConfirm', { name: dialog.entry?.name ?? '' }))
|
|
4339
|
+
: h(TextInput, {
|
|
4340
|
+
value: dialogValue,
|
|
4341
|
+
autoFocus: true,
|
|
4342
|
+
placeholder: t('drawer.files.name'),
|
|
4343
|
+
onChange: setDialogValue,
|
|
4344
|
+
onKeyDown: (event) => {
|
|
4345
|
+
if (event.key === 'Enter') void submitDialog()
|
|
4346
|
+
else if (event.key === 'Escape' && !dialogBusy) setDialog(null)
|
|
4347
|
+
},
|
|
4348
|
+
}),
|
|
4349
|
+
h(
|
|
4350
|
+
'div',
|
|
4351
|
+
{ className: 'ssh-fileDialogActions' },
|
|
4352
|
+
h(Button, { variant: 'ghost', size: 'sm', onClick: () => setDialog(null), disabled: dialogBusy }, t('common.cancel')),
|
|
4353
|
+
h(
|
|
4354
|
+
Button,
|
|
4355
|
+
{
|
|
4356
|
+
variant: dialog.mode === 'delete' ? 'dangerSolid' : 'primary',
|
|
4357
|
+
size: 'sm',
|
|
4358
|
+
autoFocus: dialog.mode === 'delete',
|
|
4359
|
+
onClick: () => void submitDialog(),
|
|
4360
|
+
disabled: dialogBusy,
|
|
4361
|
+
},
|
|
4362
|
+
dialogBusy ? h(Spinner, { size: 12 }) : null,
|
|
4363
|
+
dialog.mode === 'delete' ? t('common.delete') : t('common.create'),
|
|
4364
|
+
),
|
|
4365
|
+
),
|
|
4366
|
+
),
|
|
4367
|
+
)
|
|
4368
|
+
: null,
|
|
4197
4369
|
h(
|
|
4198
4370
|
'div',
|
|
4199
4371
|
{ className: 'ssh-fileList' },
|
|
@@ -4220,13 +4392,27 @@ window.__ModuleLoader__.load({
|
|
|
4220
4392
|
entry.name,
|
|
4221
4393
|
),
|
|
4222
4394
|
h('span', { className: 'ssh-fileSize' }, entry.directory ? t('drawer.files.dir') : formatBytes(entry.size)),
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
:
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4395
|
+
h(
|
|
4396
|
+
'div',
|
|
4397
|
+
{ className: 'ssh-fileActions' },
|
|
4398
|
+
entry.directory
|
|
4399
|
+
? null
|
|
4400
|
+
: h(
|
|
4401
|
+
IconButton,
|
|
4402
|
+
{ title: t('common.download'), onClick: () => void downloadEntry(entry) },
|
|
4403
|
+
h(IconDownload, { size: 14 }),
|
|
4404
|
+
),
|
|
4405
|
+
h(
|
|
4406
|
+
IconButton,
|
|
4407
|
+
{ title: t('drawer.files.rename'), onClick: () => openDialog('rename', entry) },
|
|
4408
|
+
h(IconEdit, { size: 14 }),
|
|
4409
|
+
),
|
|
4410
|
+
h(
|
|
4411
|
+
IconButton,
|
|
4412
|
+
{ title: t('drawer.files.delete'), onClick: () => openDialog('delete', entry) },
|
|
4413
|
+
h(IconTrash, { size: 14 }),
|
|
4414
|
+
),
|
|
4415
|
+
),
|
|
4230
4416
|
),
|
|
4231
4417
|
),
|
|
4232
4418
|
),
|
|
@@ -5333,7 +5519,13 @@ body.ssh-col-resizing,body.ssh-col-resizing *{cursor:col-resize!important;user-s
|
|
|
5333
5519
|
.ssh-filesPath{flex:1;min-width:140px}
|
|
5334
5520
|
.ssh-fileError{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--ssh-danger)}
|
|
5335
5521
|
.ssh-fileList{flex:1;min-height:0;overflow:auto;border:.5px solid var(--ssh-border);border-radius:var(--ssh-radius-md)}
|
|
5336
|
-
.ssh-fileRow{display:grid;grid-template-columns:22px minmax(0,1fr) 90px
|
|
5522
|
+
.ssh-fileRow{display:grid;grid-template-columns:22px minmax(0,1fr) 90px auto;align-items:center;gap:8px;padding:0 10px;height:34px;border-bottom:.5px solid var(--ssh-border);font-size:12px}
|
|
5523
|
+
.ssh-fileActions{display:flex;align-items:center;gap:2px}
|
|
5524
|
+
.ssh-fileDialogScrim{position:absolute;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px;background:var(--dsw-alias-bg-mask-1,color-mix(in srgb,var(--ssh-text) 18%,transparent))}
|
|
5525
|
+
.ssh-fileDialog{width:min(360px,100%);padding:14px;border:.5px solid var(--ssh-border-strong);border-radius:12px;background:var(--ssh-layer-1);box-shadow:var(--ssh-shadow);display:flex;flex-direction:column;gap:10px}
|
|
5526
|
+
.ssh-fileDialogTitle{font-size:13px;font-weight:600;color:var(--ssh-text)}
|
|
5527
|
+
.ssh-fileDialogBody{font-size:12px;line-height:1.5;color:var(--ssh-text-2)}
|
|
5528
|
+
.ssh-fileDialogActions{display:flex;justify-content:flex-end;gap:8px}
|
|
5337
5529
|
.ssh-fileRow:last-child{border-bottom:none}
|
|
5338
5530
|
.ssh-fileRow:hover{background:var(--ssh-hover)}
|
|
5339
5531
|
.ssh-fileIcon{display:inline-flex;color:var(--ssh-text-3)}
|
package/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
4
4
|
import { acceptWebSocket, isWebSocketUpgrade } from './lib/ws.js'
|
|
5
5
|
import { resolveSshPaths, listSshHosts, saveSshHost, deleteSshHost } from './lib/ssh-config.js'
|
|
6
6
|
import { deleteMetadata, loadMetadata, updateMetadata } from './lib/metadata.js'
|
|
7
|
-
import { batchExec, downloadFile, execCommand, inspectHost, listRemoteFiles, openPty, testConnection, uploadFile } from './lib/ssh-client.js'
|
|
7
|
+
import { batchExec, deleteRemotePath, downloadFile, execCommand, inspectHost, listRemoteFiles, makeRemoteDirectory, openPty, renameRemotePath, testConnection, uploadFile } from './lib/ssh-client.js'
|
|
8
8
|
import { storagePaths } from './lib/storage.js'
|
|
9
9
|
import { getPassword, listPasswordAliases, removePassword, setPassword } from './lib/vault.js'
|
|
10
10
|
|
|
@@ -31,7 +31,7 @@ const ASSETS = [
|
|
|
31
31
|
{ path: '/dsh-ssh-manage/assets/addon-search.js', file: 'addon-search.js', contentType: 'text/javascript; charset=utf-8' },
|
|
32
32
|
]
|
|
33
33
|
const MAX_BODY_BYTES = 16 * 1024 * 1024
|
|
34
|
-
const ACTIONS = ['overview', 'list_hosts', 'save_host', 'delete_host', 'set_metadata', 'save_password', 'delete_password', 'test_connection', 'inspect_host', 'exec', 'batch_exec', 'sftp_list', 'sftp_upload', 'sftp_download']
|
|
34
|
+
const ACTIONS = ['overview', 'list_hosts', 'save_host', 'delete_host', 'set_metadata', 'save_password', 'delete_password', 'test_connection', 'inspect_host', 'exec', 'batch_exec', 'sftp_list', 'sftp_upload', 'sftp_download', 'sftp_mkdir', 'sftp_rename', 'sftp_delete']
|
|
35
35
|
|
|
36
36
|
async function readJsonBody(req) {
|
|
37
37
|
const chunks = []
|
|
@@ -217,6 +217,18 @@ export async function runAction(paths, action, params = {}) {
|
|
|
217
217
|
const host = await findHost(paths, params.host)
|
|
218
218
|
return await downloadFile(host, await requirePassword(paths, host), requireString(params, 'path'))
|
|
219
219
|
}
|
|
220
|
+
case 'sftp/mkdir': {
|
|
221
|
+
const host = await findHost(paths, params.host)
|
|
222
|
+
return await makeRemoteDirectory(host, await requirePassword(paths, host), requireString(params, 'path'))
|
|
223
|
+
}
|
|
224
|
+
case 'sftp/rename': {
|
|
225
|
+
const host = await findHost(paths, params.host)
|
|
226
|
+
return await renameRemotePath(host, await requirePassword(paths, host), requireString(params, 'path'), requireString(params, 'newPath'), { overwrite: params.overwrite === true })
|
|
227
|
+
}
|
|
228
|
+
case 'sftp/delete': {
|
|
229
|
+
const host = await findHost(paths, params.host)
|
|
230
|
+
return await deleteRemotePath(host, await requirePassword(paths, host), requireString(params, 'path'))
|
|
231
|
+
}
|
|
220
232
|
default:
|
|
221
233
|
throw new Error(`Unknown action: ${String(action)}`)
|
|
222
234
|
}
|
|
@@ -241,6 +253,8 @@ function managerTool(paths) {
|
|
|
241
253
|
command: { type: 'string', description: 'Remote shell command.' },
|
|
242
254
|
hosts: { type: 'array', items: { type: 'string' }, description: 'Host aliases for a batch command.' },
|
|
243
255
|
path: { type: 'string', description: 'Remote SFTP path.' },
|
|
256
|
+
newPath: { type: 'string', description: 'New remote SFTP path for rename.' },
|
|
257
|
+
overwrite: { type: 'boolean', description: 'Allow sftp_rename to replace an existing destination (default false).' },
|
|
244
258
|
contentBase64: { type: 'string', description: 'Base64 encoded file content for upload.' },
|
|
245
259
|
metadata: { type: 'object', description: '{ group, tags, favorite, label, note, system, cpuCores, memoryGB, diskGB, latencyMs, checkedAt }' },
|
|
246
260
|
},
|
|
@@ -284,6 +298,12 @@ function managerTool(paths) {
|
|
|
284
298
|
return await runAction(paths, 'sftp/upload', params)
|
|
285
299
|
case 'sftp_download':
|
|
286
300
|
return await runAction(paths, 'sftp/download', params)
|
|
301
|
+
case 'sftp_mkdir':
|
|
302
|
+
return await runAction(paths, 'sftp/mkdir', params)
|
|
303
|
+
case 'sftp_rename':
|
|
304
|
+
return await runAction(paths, 'sftp/rename', params)
|
|
305
|
+
case 'sftp_delete':
|
|
306
|
+
return await runAction(paths, 'sftp/delete', params)
|
|
287
307
|
default:
|
|
288
308
|
throw new Error(`Unknown action: ${String(input.action)}`)
|
|
289
309
|
}
|
package/lib/ssh-client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
1
2
|
import { Client } from 'ssh2'
|
|
2
3
|
import { verifyHostKey } from './known-hosts.js'
|
|
3
4
|
|
|
@@ -105,6 +106,21 @@ function safeRemotePath(value) {
|
|
|
105
106
|
return value
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
/** POSIX-normalize a remote path; used only for guards and comparisons. */
|
|
110
|
+
export function normalizeRemotePath(value) {
|
|
111
|
+
const raw = safeRemotePath(value)
|
|
112
|
+
const normalized = path.posix.normalize(raw)
|
|
113
|
+
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isProtectedRemotePath(value) {
|
|
117
|
+
const normalized = normalizeRemotePath(value)
|
|
118
|
+
if (normalized === '' || normalized === '/' || normalized === '.' || normalized === '..') return true
|
|
119
|
+
if (normalized.startsWith('../') || normalized.includes('/../') || normalized.endsWith('/..') || normalized.includes('/./')) return true
|
|
120
|
+
if (/^[A-Za-z]:(?:\.{1,2})?$/.test(normalized)) return true
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
|
|
108
124
|
function probeInteger(value) {
|
|
109
125
|
const numeric = Number(String(value ?? '').trim())
|
|
110
126
|
if (!Number.isFinite(numeric)) return 0
|
|
@@ -170,7 +186,13 @@ export async function listRemoteFiles(host, password, remotePath = '.') {
|
|
|
170
186
|
if (error) return reject(error)
|
|
171
187
|
sftp.readdir(target, (readError, entries) => {
|
|
172
188
|
if (readError) return reject(readError)
|
|
173
|
-
resolve(entries.map((entry) => ({
|
|
189
|
+
resolve(entries.map((entry) => ({
|
|
190
|
+
name: entry.filename,
|
|
191
|
+
size: entry.attrs?.size ?? 0,
|
|
192
|
+
mode: entry.attrs?.mode ?? 0,
|
|
193
|
+
directory: Boolean(entry.attrs?.isDirectory?.()),
|
|
194
|
+
symlink: Boolean(entry.attrs?.isSymbolicLink?.()),
|
|
195
|
+
})))
|
|
174
196
|
})
|
|
175
197
|
})
|
|
176
198
|
})
|
|
@@ -208,6 +230,69 @@ export async function downloadFile(host, password, remotePath) {
|
|
|
208
230
|
} finally { client.end() }
|
|
209
231
|
}
|
|
210
232
|
|
|
233
|
+
async function withSftp(host, password, operation) {
|
|
234
|
+
const client = await connect(host, password)
|
|
235
|
+
try {
|
|
236
|
+
const sftp = await new Promise((resolve, reject) => client.sftp((error, value) => error ? reject(error) : resolve(value)))
|
|
237
|
+
return await operation(sftp)
|
|
238
|
+
} finally {
|
|
239
|
+
client.end()
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function makeRemoteDirectory(host, password, remotePath) {
|
|
244
|
+
const target = safeRemotePath(remotePath)
|
|
245
|
+
await withSftp(host, password, (sftp) => new Promise((resolve, reject) => {
|
|
246
|
+
sftp.mkdir(target, (error) => error ? reject(error) : resolve())
|
|
247
|
+
}))
|
|
248
|
+
return { created: true, path: target }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function renameRemotePath(host, password, remotePath, nextPath, options = {}) {
|
|
252
|
+
const from = safeRemotePath(remotePath)
|
|
253
|
+
const to = safeRemotePath(nextPath)
|
|
254
|
+
if (normalizeRemotePath(from) === normalizeRemotePath(to)) throw new Error('Source and destination are the same')
|
|
255
|
+
if (isProtectedRemotePath(to)) throw new Error('Refusing to rename onto a root path')
|
|
256
|
+
await withSftp(host, password, async (sftp) => {
|
|
257
|
+
if (options.overwrite !== true) {
|
|
258
|
+
const exists = await new Promise((resolve, reject) => {
|
|
259
|
+
sftp.stat(to, (error) => {
|
|
260
|
+
if (error) {
|
|
261
|
+
if (error.code === 2) resolve(false)
|
|
262
|
+
else reject(error)
|
|
263
|
+
} else resolve(true)
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
if (exists) throw new Error(`Destination already exists: ${to}`)
|
|
267
|
+
}
|
|
268
|
+
await new Promise((resolve, reject) => {
|
|
269
|
+
sftp.rename(from, to, (error) => error ? reject(error) : resolve())
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
return { renamed: true, from, to }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function deleteRemotePath(host, password, remotePath) {
|
|
276
|
+
const target = safeRemotePath(remotePath)
|
|
277
|
+
if (isProtectedRemotePath(target)) {
|
|
278
|
+
throw new Error('Refusing to delete a root or empty path')
|
|
279
|
+
}
|
|
280
|
+
const result = await withSftp(host, password, async (sftp) => {
|
|
281
|
+
const stats = await new Promise((resolve, reject) => {
|
|
282
|
+
sftp.lstat(target, (error, value) => error ? reject(error) : resolve(value))
|
|
283
|
+
})
|
|
284
|
+
const symbolicLink = Boolean(stats?.isSymbolicLink?.())
|
|
285
|
+
const directory = !symbolicLink && Boolean(stats?.isDirectory?.())
|
|
286
|
+
await new Promise((resolve, reject) => {
|
|
287
|
+
const done = (error) => error ? reject(error) : resolve()
|
|
288
|
+
if (directory) sftp.rmdir(target, done)
|
|
289
|
+
else sftp.unlink(target, done)
|
|
290
|
+
})
|
|
291
|
+
return { directory, symbolicLink }
|
|
292
|
+
})
|
|
293
|
+
return { deleted: true, directory: result.directory, symbolicLink: result.symbolicLink, path: target }
|
|
294
|
+
}
|
|
295
|
+
|
|
211
296
|
export async function batchExec(hosts, passwordFor, command, options = {}) {
|
|
212
297
|
const concurrency = Math.min(Math.max(Number(options.concurrency || 4), 1), 16)
|
|
213
298
|
const results = []
|
|
@@ -270,4 +355,4 @@ export function openInteractiveShell(host, password, onReady, options = {}) {
|
|
|
270
355
|
})
|
|
271
356
|
}
|
|
272
357
|
|
|
273
|
-
export const __test = { safeRemotePath, connectionConfig, parseProbeOutput, MAX_OUTPUT_BYTES, MAX_FILE_BYTES }
|
|
358
|
+
export const __test = { safeRemotePath, normalizeRemotePath, isProtectedRemotePath, connectionConfig, parseProbeOutput, MAX_OUTPUT_BYTES, MAX_FILE_BYTES }
|