dsh-ssh-manage 0.3.1 → 0.3.3
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 +306 -31
- 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.3`).
|
|
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.3
|
|
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.3
|
|
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.3`)。
|
|
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.3
|
|
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.3
|
|
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
|
@@ -134,6 +134,9 @@ window.__ModuleLoader__.load({
|
|
|
134
134
|
'editor.port': ['端口', 'Port'],
|
|
135
135
|
'editor.group': ['分组', 'Group'],
|
|
136
136
|
'editor.groupPlaceholder': ['例如 production', 'e.g. production'],
|
|
137
|
+
'editor.groupChoose': ['选择已有分组', 'Choose an existing group'],
|
|
138
|
+
'editor.groupNone': ['无分组', 'No group'],
|
|
139
|
+
'editor.groupNoOptions': ['还没有已有分组,直接输入新名称即可。', 'No groups yet; type a new name.'],
|
|
137
140
|
'editor.tags': ['标签', 'Tags'],
|
|
138
141
|
'editor.tagsPlaceholder': ['web, critical(逗号分隔)', 'web, critical (comma separated)'],
|
|
139
142
|
'editor.label': ['显示名称', 'Display name'],
|
|
@@ -182,6 +185,19 @@ window.__ModuleLoader__.load({
|
|
|
182
185
|
'drawer.files.path': ['远程路径', 'Remote path'],
|
|
183
186
|
'drawer.files.up': ['上级目录', 'Parent directory'],
|
|
184
187
|
'drawer.files.empty': ['目录暂无内容。', 'This directory is empty.'],
|
|
188
|
+
'drawer.files.mkdir': ['新建文件夹', 'New folder'],
|
|
189
|
+
'drawer.files.mkdirTitle': ['新建文件夹', 'New folder'],
|
|
190
|
+
'drawer.files.rename': ['重命名', 'Rename'],
|
|
191
|
+
'drawer.files.renameTitle': ['重命名', 'Rename'],
|
|
192
|
+
'drawer.files.delete': ['删除', 'Delete'],
|
|
193
|
+
'drawer.files.deleteTitle': ['删除', 'Delete'],
|
|
194
|
+
'drawer.files.deleteConfirm': ['确定删除“{name}”?删除目录时要求目录为空。', 'Delete "{name}"? Directories must be empty.'],
|
|
195
|
+
'drawer.files.name': ['名称', 'Name'],
|
|
196
|
+
'drawer.files.invalidName': ['名称不能为空,也不能包含 / 或 \\。', 'Name must be non-empty and cannot contain / or \\.'],
|
|
197
|
+
'drawer.files.created': ['文件夹已创建', 'Folder created'],
|
|
198
|
+
'drawer.files.renamed': ['已重命名', 'Renamed'],
|
|
199
|
+
'drawer.files.deleted': ['已删除', 'Deleted'],
|
|
200
|
+
'common.create': ['新建', 'Create'],
|
|
185
201
|
'drawer.files.dir': ['目录', 'dir'],
|
|
186
202
|
'drawer.files.uploading': ['上传中…', 'Uploading…'],
|
|
187
203
|
'drawer.details.connection': ['连接信息', 'Connection'],
|
|
@@ -793,7 +809,7 @@ window.__ModuleLoader__.load({
|
|
|
793
809
|
return h('span', { className: 'ssh-spinner', style: { width: size, height: size } })
|
|
794
810
|
}
|
|
795
811
|
|
|
796
|
-
function Button({ children, variant, size, onClick, disabled, title, type = 'button', className, ariaPressed }) {
|
|
812
|
+
function Button({ children, variant, size, onClick, disabled, title, type = 'button', className, ariaPressed, autoFocus }) {
|
|
797
813
|
return h(
|
|
798
814
|
'button',
|
|
799
815
|
{
|
|
@@ -803,6 +819,7 @@ window.__ModuleLoader__.load({
|
|
|
803
819
|
disabled,
|
|
804
820
|
title,
|
|
805
821
|
'aria-pressed': ariaPressed,
|
|
822
|
+
autoFocus,
|
|
806
823
|
},
|
|
807
824
|
children,
|
|
808
825
|
)
|
|
@@ -4088,25 +4105,50 @@ window.__ModuleLoader__.load({
|
|
|
4088
4105
|
)
|
|
4089
4106
|
}
|
|
4090
4107
|
|
|
4108
|
+
const SFTP_PATH_PREFIX = 'dsh-ssh-manage:sftp-path:'
|
|
4109
|
+
|
|
4110
|
+
function readSftpPath(alias) {
|
|
4111
|
+
try {
|
|
4112
|
+
const stored = window.localStorage.getItem(SFTP_PATH_PREFIX + alias)
|
|
4113
|
+
return stored && stored.trim() ? stored : '.'
|
|
4114
|
+
} catch {
|
|
4115
|
+
return '.'
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
4118
|
+
|
|
4119
|
+
function saveSftpPath(alias, target) {
|
|
4120
|
+
try {
|
|
4121
|
+
window.localStorage.setItem(SFTP_PATH_PREFIX + alias, target)
|
|
4122
|
+
} catch {}
|
|
4123
|
+
}
|
|
4124
|
+
|
|
4091
4125
|
function FilesView({ model, host }) {
|
|
4092
|
-
const { t, notify
|
|
4093
|
-
const [path, setPath] = React.useState(
|
|
4126
|
+
const { t, notify } = model
|
|
4127
|
+
const [path, setPath] = React.useState(() => readSftpPath(host.host))
|
|
4094
4128
|
const [entries, setEntries] = React.useState([])
|
|
4095
4129
|
const [loading, setLoading] = React.useState(true)
|
|
4096
4130
|
const [error, setError] = React.useState('')
|
|
4097
4131
|
const [uploading, setUploading] = React.useState(false)
|
|
4132
|
+
const [dialog, setDialog] = React.useState(null)
|
|
4133
|
+
const [dialogValue, setDialogValue] = React.useState('')
|
|
4134
|
+
const [dialogBusy, setDialogBusy] = React.useState(false)
|
|
4135
|
+
const dialogBusyRef = React.useRef(false)
|
|
4098
4136
|
const fileRef = React.useRef(null)
|
|
4099
4137
|
|
|
4100
4138
|
const loadDirectory = React.useCallback(
|
|
4101
|
-
async (target) => {
|
|
4139
|
+
async (target, options = {}) => {
|
|
4140
|
+
const normalized = String(target ?? '').trim() || '.'
|
|
4102
4141
|
setLoading(true)
|
|
4103
4142
|
setError('')
|
|
4104
4143
|
try {
|
|
4105
|
-
const value = await callAction('sftp/list', { host: host.host, path:
|
|
4144
|
+
const value = await callAction('sftp/list', { host: host.host, path: normalized })
|
|
4106
4145
|
setEntries(value.entries ?? [])
|
|
4107
|
-
setPath(
|
|
4146
|
+
setPath(normalized)
|
|
4147
|
+
if (options.remember !== false) saveSftpPath(host.host, normalized)
|
|
4148
|
+
return true
|
|
4108
4149
|
} catch (failure) {
|
|
4109
4150
|
setError(errorMessage(failure))
|
|
4151
|
+
return false
|
|
4110
4152
|
} finally {
|
|
4111
4153
|
setLoading(false)
|
|
4112
4154
|
}
|
|
@@ -4115,8 +4157,72 @@ window.__ModuleLoader__.load({
|
|
|
4115
4157
|
)
|
|
4116
4158
|
|
|
4117
4159
|
React.useEffect(() => {
|
|
4118
|
-
|
|
4119
|
-
|
|
4160
|
+
let cancelled = false
|
|
4161
|
+
void (async () => {
|
|
4162
|
+
const initial = readSftpPath(host.host)
|
|
4163
|
+
const ok = await loadDirectory(initial)
|
|
4164
|
+
if (!ok && initial !== '.' && !cancelled) await loadDirectory('.', { remember: false })
|
|
4165
|
+
})()
|
|
4166
|
+
return () => { cancelled = true }
|
|
4167
|
+
}, [host.host, loadDirectory])
|
|
4168
|
+
|
|
4169
|
+
React.useEffect(() => {
|
|
4170
|
+
if (!dialog) return undefined
|
|
4171
|
+
const onKey = (event) => {
|
|
4172
|
+
if (event.key === 'Escape' && !dialogBusy) {
|
|
4173
|
+
event.preventDefault()
|
|
4174
|
+
event.stopPropagation()
|
|
4175
|
+
setDialog(null)
|
|
4176
|
+
}
|
|
4177
|
+
}
|
|
4178
|
+
document.addEventListener('keydown', onKey, true)
|
|
4179
|
+
return () => document.removeEventListener('keydown', onKey, true)
|
|
4180
|
+
}, [dialog, dialogBusy])
|
|
4181
|
+
|
|
4182
|
+
const openDialog = (mode, entry = null) => {
|
|
4183
|
+
setDialog({ mode, entry })
|
|
4184
|
+
setDialogValue(mode === 'rename' && entry ? entry.name : '')
|
|
4185
|
+
}
|
|
4186
|
+
|
|
4187
|
+
async function submitDialog() {
|
|
4188
|
+
if (!dialog || dialogBusyRef.current) return
|
|
4189
|
+
dialogBusyRef.current = true
|
|
4190
|
+
const name = dialogValue.trim()
|
|
4191
|
+
if (dialog.mode !== 'delete') {
|
|
4192
|
+
if (!name || name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
|
|
4193
|
+
notify(t('drawer.files.invalidName'), 'error')
|
|
4194
|
+
return
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
4197
|
+
setDialogBusy(true)
|
|
4198
|
+
try {
|
|
4199
|
+
if (dialog.mode === 'mkdir') {
|
|
4200
|
+
await callAction('sftp/mkdir', { host: host.host, path: remoteJoin(path, name) })
|
|
4201
|
+
notify(t('drawer.files.created'), 'success')
|
|
4202
|
+
} else if (dialog.mode === 'rename') {
|
|
4203
|
+
if (name === dialog.entry.name) {
|
|
4204
|
+
setDialog(null)
|
|
4205
|
+
return
|
|
4206
|
+
}
|
|
4207
|
+
await callAction('sftp/rename', {
|
|
4208
|
+
host: host.host,
|
|
4209
|
+
path: remoteJoin(path, dialog.entry.name),
|
|
4210
|
+
newPath: remoteJoin(path, name),
|
|
4211
|
+
})
|
|
4212
|
+
notify(t('drawer.files.renamed'), 'success')
|
|
4213
|
+
} else if (dialog.mode === 'delete') {
|
|
4214
|
+
await callAction('sftp/delete', { host: host.host, path: remoteJoin(path, dialog.entry.name) })
|
|
4215
|
+
notify(t('drawer.files.deleted'), 'success')
|
|
4216
|
+
}
|
|
4217
|
+
setDialog(null)
|
|
4218
|
+
await loadDirectory(path)
|
|
4219
|
+
} catch (failure) {
|
|
4220
|
+
notify(t('state.error', { message: errorMessage(failure) }), 'error')
|
|
4221
|
+
} finally {
|
|
4222
|
+
dialogBusyRef.current = false
|
|
4223
|
+
setDialogBusy(false)
|
|
4224
|
+
}
|
|
4225
|
+
}
|
|
4120
4226
|
|
|
4121
4227
|
async function downloadEntry(entry) {
|
|
4122
4228
|
const remote = remoteJoin(path, entry.name)
|
|
@@ -4183,6 +4289,12 @@ window.__ModuleLoader__.load({
|
|
|
4183
4289
|
{ title: t('common.refresh'), onClick: () => void loadDirectory(path), disabled: loading },
|
|
4184
4290
|
loading ? h(Spinner, { size: 13 }) : h(IconRefresh, { size: 15 }),
|
|
4185
4291
|
),
|
|
4292
|
+
h(
|
|
4293
|
+
Button,
|
|
4294
|
+
{ size: 'sm', variant: 'ghost', onClick: () => openDialog('mkdir'), disabled: loading || uploading },
|
|
4295
|
+
h(IconPlus, { size: 13 }),
|
|
4296
|
+
t('drawer.files.mkdir'),
|
|
4297
|
+
),
|
|
4186
4298
|
h(
|
|
4187
4299
|
Button,
|
|
4188
4300
|
{ size: 'sm', onClick: () => fileRef.current && fileRef.current.click(), disabled: uploading },
|
|
@@ -4194,6 +4306,69 @@ window.__ModuleLoader__.load({
|
|
|
4194
4306
|
error
|
|
4195
4307
|
? h('div', { className: 'ssh-fileError' }, h(IconAlert, { size: 13 }), h('span', null, error))
|
|
4196
4308
|
: null,
|
|
4309
|
+
dialog
|
|
4310
|
+
? h(
|
|
4311
|
+
'div',
|
|
4312
|
+
{
|
|
4313
|
+
className: 'ssh-fileDialogScrim',
|
|
4314
|
+
onMouseDown: () => {
|
|
4315
|
+
if (!dialogBusy) setDialog(null)
|
|
4316
|
+
},
|
|
4317
|
+
},
|
|
4318
|
+
h(
|
|
4319
|
+
'div',
|
|
4320
|
+
{
|
|
4321
|
+
className: 'ssh-fileDialog',
|
|
4322
|
+
role: 'dialog',
|
|
4323
|
+
'aria-modal': 'true',
|
|
4324
|
+
'aria-label': dialog.mode === 'mkdir'
|
|
4325
|
+
? t('drawer.files.mkdirTitle')
|
|
4326
|
+
: dialog.mode === 'rename'
|
|
4327
|
+
? t('drawer.files.renameTitle')
|
|
4328
|
+
: t('drawer.files.deleteTitle'),
|
|
4329
|
+
onMouseDown: (event) => event.stopPropagation(),
|
|
4330
|
+
},
|
|
4331
|
+
h(
|
|
4332
|
+
'div',
|
|
4333
|
+
{ className: 'ssh-fileDialogTitle' },
|
|
4334
|
+
dialog.mode === 'mkdir'
|
|
4335
|
+
? t('drawer.files.mkdirTitle')
|
|
4336
|
+
: dialog.mode === 'rename'
|
|
4337
|
+
? t('drawer.files.renameTitle')
|
|
4338
|
+
: t('drawer.files.deleteTitle'),
|
|
4339
|
+
),
|
|
4340
|
+
dialog.mode === 'delete'
|
|
4341
|
+
? h('div', { className: 'ssh-fileDialogBody' }, t('drawer.files.deleteConfirm', { name: dialog.entry?.name ?? '' }))
|
|
4342
|
+
: h(TextInput, {
|
|
4343
|
+
value: dialogValue,
|
|
4344
|
+
autoFocus: true,
|
|
4345
|
+
placeholder: t('drawer.files.name'),
|
|
4346
|
+
onChange: setDialogValue,
|
|
4347
|
+
onKeyDown: (event) => {
|
|
4348
|
+
if (event.key === 'Enter') void submitDialog()
|
|
4349
|
+
else if (event.key === 'Escape' && !dialogBusy) setDialog(null)
|
|
4350
|
+
},
|
|
4351
|
+
}),
|
|
4352
|
+
h(
|
|
4353
|
+
'div',
|
|
4354
|
+
{ className: 'ssh-fileDialogActions' },
|
|
4355
|
+
h(Button, { variant: 'ghost', size: 'sm', onClick: () => setDialog(null), disabled: dialogBusy }, t('common.cancel')),
|
|
4356
|
+
h(
|
|
4357
|
+
Button,
|
|
4358
|
+
{
|
|
4359
|
+
variant: dialog.mode === 'delete' ? 'dangerSolid' : 'primary',
|
|
4360
|
+
size: 'sm',
|
|
4361
|
+
autoFocus: dialog.mode === 'delete',
|
|
4362
|
+
onClick: () => void submitDialog(),
|
|
4363
|
+
disabled: dialogBusy,
|
|
4364
|
+
},
|
|
4365
|
+
dialogBusy ? h(Spinner, { size: 12 }) : null,
|
|
4366
|
+
dialog.mode === 'delete' ? t('common.delete') : t('common.create'),
|
|
4367
|
+
),
|
|
4368
|
+
),
|
|
4369
|
+
),
|
|
4370
|
+
)
|
|
4371
|
+
: null,
|
|
4197
4372
|
h(
|
|
4198
4373
|
'div',
|
|
4199
4374
|
{ className: 'ssh-fileList' },
|
|
@@ -4220,13 +4395,27 @@ window.__ModuleLoader__.load({
|
|
|
4220
4395
|
entry.name,
|
|
4221
4396
|
),
|
|
4222
4397
|
h('span', { className: 'ssh-fileSize' }, entry.directory ? t('drawer.files.dir') : formatBytes(entry.size)),
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
:
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4398
|
+
h(
|
|
4399
|
+
'div',
|
|
4400
|
+
{ className: 'ssh-fileActions' },
|
|
4401
|
+
entry.directory
|
|
4402
|
+
? null
|
|
4403
|
+
: h(
|
|
4404
|
+
IconButton,
|
|
4405
|
+
{ title: t('common.download'), onClick: () => void downloadEntry(entry) },
|
|
4406
|
+
h(IconDownload, { size: 14 }),
|
|
4407
|
+
),
|
|
4408
|
+
h(
|
|
4409
|
+
IconButton,
|
|
4410
|
+
{ title: t('drawer.files.rename'), onClick: () => openDialog('rename', entry) },
|
|
4411
|
+
h(IconEdit, { size: 14 }),
|
|
4412
|
+
),
|
|
4413
|
+
h(
|
|
4414
|
+
IconButton,
|
|
4415
|
+
{ title: t('drawer.files.delete'), onClick: () => openDialog('delete', entry) },
|
|
4416
|
+
h(IconTrash, { size: 14 }),
|
|
4417
|
+
),
|
|
4418
|
+
),
|
|
4230
4419
|
),
|
|
4231
4420
|
),
|
|
4232
4421
|
),
|
|
@@ -4404,6 +4593,9 @@ window.__ModuleLoader__.load({
|
|
|
4404
4593
|
const [password, setPassword] = React.useState('')
|
|
4405
4594
|
const [passwordError, setPasswordError] = React.useState('')
|
|
4406
4595
|
const [formError, setFormError] = React.useState('')
|
|
4596
|
+
const [groupMenuOpen, setGroupMenuOpen] = React.useState(false)
|
|
4597
|
+
const groupMenuRef = React.useRef(null)
|
|
4598
|
+
const groupOptions = (model.allGroups ?? []).filter((group) => group.key !== '').map((group) => group.key)
|
|
4407
4599
|
const busy = isPending('editor')
|
|
4408
4600
|
const passwordRequired = !original || original.passwordConfigured !== true
|
|
4409
4601
|
|
|
@@ -4433,6 +4625,27 @@ window.__ModuleLoader__.load({
|
|
|
4433
4625
|
}
|
|
4434
4626
|
})
|
|
4435
4627
|
|
|
4628
|
+
React.useEffect(() => {
|
|
4629
|
+
if (!groupMenuOpen) return undefined
|
|
4630
|
+
const close = (event) => {
|
|
4631
|
+
if (groupMenuRef.current && groupMenuRef.current.contains(event.target)) return
|
|
4632
|
+
setGroupMenuOpen(false)
|
|
4633
|
+
}
|
|
4634
|
+
const onKey = (event) => {
|
|
4635
|
+
if (event.key === 'Escape') {
|
|
4636
|
+
event.preventDefault()
|
|
4637
|
+
event.stopPropagation()
|
|
4638
|
+
setGroupMenuOpen(false)
|
|
4639
|
+
}
|
|
4640
|
+
}
|
|
4641
|
+
document.addEventListener('mousedown', close)
|
|
4642
|
+
document.addEventListener('keydown', onKey, true)
|
|
4643
|
+
return () => {
|
|
4644
|
+
document.removeEventListener('mousedown', close)
|
|
4645
|
+
document.removeEventListener('keydown', onKey, true)
|
|
4646
|
+
}
|
|
4647
|
+
}, [groupMenuOpen])
|
|
4648
|
+
|
|
4436
4649
|
async function submit() {
|
|
4437
4650
|
const alias = draft.host.trim()
|
|
4438
4651
|
const hostname = draft.hostname.trim()
|
|
@@ -4559,11 +4772,59 @@ window.__ModuleLoader__.load({
|
|
|
4559
4772
|
h(
|
|
4560
4773
|
Field,
|
|
4561
4774
|
{ label: t('editor.group') },
|
|
4562
|
-
h(
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4775
|
+
h(
|
|
4776
|
+
'div',
|
|
4777
|
+
{ className: 'ssh-groupCombo', ref: groupMenuRef },
|
|
4778
|
+
h(TextInput, {
|
|
4779
|
+
value: draft.group,
|
|
4780
|
+
onChange: (value) => update('group', value),
|
|
4781
|
+
placeholder: t('editor.groupPlaceholder'),
|
|
4782
|
+
}),
|
|
4783
|
+
h(
|
|
4784
|
+
'button',
|
|
4785
|
+
{
|
|
4786
|
+
type: 'button',
|
|
4787
|
+
className: 'ssh-groupToggle',
|
|
4788
|
+
title: t('editor.groupChoose'),
|
|
4789
|
+
'aria-label': t('editor.groupChoose'),
|
|
4790
|
+
'aria-haspopup': 'listbox',
|
|
4791
|
+
'aria-expanded': groupMenuOpen,
|
|
4792
|
+
onClick: () => setGroupMenuOpen((value) => !value),
|
|
4793
|
+
},
|
|
4794
|
+
h(IconChevronDown, { size: 13 }),
|
|
4795
|
+
),
|
|
4796
|
+
groupMenuOpen
|
|
4797
|
+
? h(
|
|
4798
|
+
'div',
|
|
4799
|
+
{ className: 'ssh-groupMenu', role: 'listbox' },
|
|
4800
|
+
h('button', {
|
|
4801
|
+
type: 'button',
|
|
4802
|
+
role: 'option',
|
|
4803
|
+
'aria-selected': draft.group.trim() === '',
|
|
4804
|
+
className: cx('ssh-groupOption', draft.group.trim() === '' && 'active'),
|
|
4805
|
+
onClick: () => {
|
|
4806
|
+
update('group', '')
|
|
4807
|
+
setGroupMenuOpen(false)
|
|
4808
|
+
},
|
|
4809
|
+
}, t('editor.groupNone')),
|
|
4810
|
+
groupOptions.length === 0
|
|
4811
|
+
? h('div', { className: 'ssh-groupEmpty' }, t('editor.groupNoOptions'))
|
|
4812
|
+
: groupOptions.map((option) =>
|
|
4813
|
+
h('button', {
|
|
4814
|
+
key: option,
|
|
4815
|
+
type: 'button',
|
|
4816
|
+
role: 'option',
|
|
4817
|
+
'aria-selected': draft.group.trim() === option,
|
|
4818
|
+
className: cx('ssh-groupOption', draft.group.trim() === option && 'active'),
|
|
4819
|
+
onClick: () => {
|
|
4820
|
+
update('group', option)
|
|
4821
|
+
setGroupMenuOpen(false)
|
|
4822
|
+
},
|
|
4823
|
+
}, option),
|
|
4824
|
+
),
|
|
4825
|
+
)
|
|
4826
|
+
: null,
|
|
4827
|
+
),
|
|
4567
4828
|
),
|
|
4568
4829
|
h(
|
|
4569
4830
|
Field,
|
|
@@ -5112,7 +5373,7 @@ body.ssh-col-resizing,body.ssh-col-resizing *{cursor:col-resize!important;user-s
|
|
|
5112
5373
|
.ssh-mainTop{flex:0 0 auto;display:flex;align-items:center;flex-wrap:wrap;gap:8px;row-gap:6px;padding:10px 14px;border-bottom:.5px solid var(--ssh-border);min-height:56px}
|
|
5113
5374
|
.ssh-context{display:flex;align-items:baseline;gap:6px;min-width:0;max-width:180px;flex:0 0 auto}
|
|
5114
5375
|
.ssh-contextName{font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
5115
|
-
.ssh-contextCount{font-size:11px;color:var(--ssh-text-
|
|
5376
|
+
.ssh-contextCount{font-size:11px;color:var(--ssh-text-2);font-variant-numeric:tabular-nums}
|
|
5116
5377
|
.ssh-search{position:relative;display:flex;align-items:center;flex:0 1 300px;min-width:120px}
|
|
5117
5378
|
.ssh-search > svg{position:absolute;left:10px;color:var(--ssh-dim);pointer-events:none;z-index:1}
|
|
5118
5379
|
.ssh-search .ssh-input{height:30px;padding-left:31px;border-radius:999px;background:var(--ssh-layer-1);font-size:12px}
|
|
@@ -5126,7 +5387,7 @@ body.ssh-col-resizing,body.ssh-col-resizing *{cursor:col-resize!important;user-s
|
|
|
5126
5387
|
.ssh-table{flex:1;min-height:0;display:flex;flex-direction:column;min-width:0}
|
|
5127
5388
|
.ssh-tableScroll{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column}
|
|
5128
5389
|
.ssh-grid{display:grid;align-items:center;column-gap:8px;padding:0 12px;width:100%;min-width:0}
|
|
5129
|
-
.ssh-head{flex:0 0 auto;height:38px;border-bottom:.5px solid var(--ssh-border);color:var(--ssh-text-
|
|
5390
|
+
.ssh-head{flex:0 0 auto;height:38px;border-bottom:.5px solid var(--ssh-border);color:var(--ssh-text-2);font-size:11px;font-weight:500;background:var(--ssh-bg);position:sticky;top:0;z-index:3;min-width:640px}
|
|
5130
5391
|
.ssh-cell{min-width:0;display:flex;align-items:center;gap:6px;overflow:hidden}
|
|
5131
5392
|
.ssh-headBtn{appearance:none;display:inline-flex;align-items:center;gap:4px;padding:0;border:0;background:transparent;color:inherit;font:inherit;font-size:11px;font-weight:500;cursor:pointer;white-space:nowrap;min-width:0}
|
|
5132
5393
|
.ssh-headBtn:hover{color:var(--ssh-text)}
|
|
@@ -5144,32 +5405,32 @@ body.ssh-col-resizing,body.ssh-col-resizing *{cursor:col-resize!important;user-s
|
|
|
5144
5405
|
.ssh-colLatency{min-width:0}
|
|
5145
5406
|
.ssh-colSystem{min-width:0}
|
|
5146
5407
|
.ssh-osBadge{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:8px;background:var(--ssh-layer-3);color:var(--ssh-brand)}
|
|
5147
|
-
.ssh-osBadge.muted{color:var(--ssh-
|
|
5408
|
+
.ssh-osBadge.muted{color:var(--ssh-text-2)}
|
|
5148
5409
|
.ssh-osName{font-size:11.5px;color:var(--ssh-text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
5149
|
-
.ssh-osName.muted{color:var(--ssh-
|
|
5410
|
+
.ssh-osName.muted{color:var(--ssh-text-2)}
|
|
5150
5411
|
.ssh-latency{appearance:none;display:inline-flex;align-items:center;gap:5px;height:24px;padding:0;border:0;border-radius:4px;background:transparent;color:var(--ssh-text-2);font:inherit;font-size:11.5px;white-space:nowrap;cursor:pointer}
|
|
5151
|
-
.ssh-latency.ok{color:var(--
|
|
5152
|
-
.ssh-latency.muted{color:var(--ssh-
|
|
5412
|
+
.ssh-latency.ok{color:var(--ssh-success)}
|
|
5413
|
+
.ssh-latency.muted{color:var(--ssh-text-2)}
|
|
5153
5414
|
.ssh-latency:hover:not(:disabled){color:var(--ssh-text)}
|
|
5154
5415
|
.ssh-latency:disabled{cursor:default}
|
|
5155
5416
|
.ssh-latencyDot{width:6px;height:6px;border-radius:50%;background:currentColor;opacity:.55}
|
|
5156
5417
|
.ssh-latencyIcon{display:inline-flex;color:var(--dsw-alias-state-success-label,var(--ssh-success))}
|
|
5157
5418
|
.ssh-colName{flex-direction:column;align-items:flex-start;gap:0}
|
|
5158
5419
|
.ssh-nameMain{display:flex;align-items:center;gap:5px;max-width:100%;font-size:12.5px;font-weight:500;color:var(--ssh-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
5159
|
-
.ssh-nameSub{max-width:100%;font-size:10.5px;color:var(--ssh-
|
|
5420
|
+
.ssh-nameSub{max-width:100%;font-size:10.5px;color:var(--ssh-text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
5160
5421
|
.ssh-favMark{color:var(--ssh-warn);display:inline-flex;flex:0 0 auto}
|
|
5161
5422
|
.ssh-colAddress{gap:3px;min-width:0}
|
|
5162
|
-
.ssh-address{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--ssh-text
|
|
5423
|
+
.ssh-address{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--ssh-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
|
5163
5424
|
.ssh-inlineActions{display:inline-flex;gap:1px;flex:0 0 auto;opacity:.45;transition:opacity .12s ease}
|
|
5164
5425
|
.ssh-row:hover .ssh-inlineActions{opacity:1}
|
|
5165
5426
|
.ssh-miniBtn{appearance:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:transparent;color:var(--ssh-text-3);cursor:pointer}
|
|
5166
5427
|
.ssh-miniBtn:hover{background:var(--ssh-hover);color:var(--ssh-text)}
|
|
5167
5428
|
.ssh-colInfo{gap:4px;row-gap:3px;flex-wrap:wrap;align-content:center;overflow:visible}
|
|
5168
5429
|
.ssh-chip{display:inline-flex;flex:0 0 auto;align-items:center;height:21px;max-width:100%;padding:0 7px;border-radius:6px;border:.5px solid var(--ssh-border);background:var(--ssh-layer-3);font-size:10.5px;color:var(--ssh-text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
5169
|
-
.ssh-mutedText{font-size:11.5px;color:var(--ssh-
|
|
5430
|
+
.ssh-mutedText{font-size:11.5px;color:var(--ssh-text-2)}
|
|
5170
5431
|
.ssh-colNotes{min-width:0}
|
|
5171
5432
|
.ssh-noteText{font-size:11.5px;color:var(--ssh-text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
5172
|
-
.ssh-noteText.muted{color:var(--ssh-
|
|
5433
|
+
.ssh-noteText.muted{color:var(--ssh-text-2)}
|
|
5173
5434
|
.ssh-colActions{gap:4px;justify-content:flex-start;overflow:visible}
|
|
5174
5435
|
.ssh-colGear{justify-content:flex-end}
|
|
5175
5436
|
.ssh-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:56px 24px;text-align:center;color:var(--ssh-text-3);font-size:12px;min-height:0}
|
|
@@ -5205,6 +5466,14 @@ body.ssh-col-resizing,body.ssh-col-resizing *{cursor:col-resize!important;user-s
|
|
|
5205
5466
|
.ssh-input.mono,.ssh-textarea.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px}
|
|
5206
5467
|
.ssh-textarea{height:auto;padding:9px 11px;line-height:1.65;resize:vertical;min-height:74px}
|
|
5207
5468
|
.ssh-field{display:flex;flex-direction:column;gap:5px;min-width:0}
|
|
5469
|
+
.ssh-groupCombo{position:relative;display:flex;align-items:center;gap:4px}
|
|
5470
|
+
.ssh-groupCombo .ssh-input{flex:1;min-width:0}
|
|
5471
|
+
.ssh-groupToggle{appearance:none;display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border:.5px solid var(--ssh-border-strong);border-radius:var(--ssh-radius-sm);background:var(--ssh-layer-2);color:var(--ssh-text-2);cursor:pointer;flex:0 0 auto}
|
|
5472
|
+
.ssh-groupToggle:hover{background:var(--ssh-hover);color:var(--ssh-text)}
|
|
5473
|
+
.ssh-groupMenu{position:absolute;z-index:12;top:calc(100% + 5px);left:0;right:0;max-height:190px;overflow:auto;padding:4px;border:.5px solid var(--ssh-border-strong);border-radius:var(--ssh-radius-md);background:var(--ssh-layer-1);box-shadow:var(--ssh-shadow)}
|
|
5474
|
+
.ssh-groupOption{appearance:none;display:block;width:100%;padding:6px 8px;border:0;border-radius:6px;background:transparent;color:var(--ssh-text);font:inherit;font-size:12px;text-align:left;cursor:pointer}
|
|
5475
|
+
.ssh-groupOption:hover,.ssh-groupOption.active{background:var(--ssh-hover)}
|
|
5476
|
+
.ssh-groupEmpty{padding:6px 8px;font-size:11.5px;color:var(--ssh-text-2)}
|
|
5208
5477
|
.ssh-field.wide{grid-column:1/-1}
|
|
5209
5478
|
.ssh-label{font-size:11.5px;font-weight:500;color:var(--ssh-text-2)}
|
|
5210
5479
|
.ssh-hint{font-size:10.5px;color:var(--ssh-text-3);line-height:1.55}
|
|
@@ -5333,7 +5602,13 @@ body.ssh-col-resizing,body.ssh-col-resizing *{cursor:col-resize!important;user-s
|
|
|
5333
5602
|
.ssh-filesPath{flex:1;min-width:140px}
|
|
5334
5603
|
.ssh-fileError{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--ssh-danger)}
|
|
5335
5604
|
.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
|
|
5605
|
+
.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}
|
|
5606
|
+
.ssh-fileActions{display:flex;align-items:center;gap:2px}
|
|
5607
|
+
.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))}
|
|
5608
|
+
.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}
|
|
5609
|
+
.ssh-fileDialogTitle{font-size:13px;font-weight:600;color:var(--ssh-text)}
|
|
5610
|
+
.ssh-fileDialogBody{font-size:12px;line-height:1.5;color:var(--ssh-text-2)}
|
|
5611
|
+
.ssh-fileDialogActions{display:flex;justify-content:flex-end;gap:8px}
|
|
5337
5612
|
.ssh-fileRow:last-child{border-bottom:none}
|
|
5338
5613
|
.ssh-fileRow:hover{background:var(--ssh-hover)}
|
|
5339
5614
|
.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 }
|