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 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, upload and download from the host drawer, starting at the current user's home directory.
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.1`).
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.1
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.1
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.1`** (validated on DSH Desktop `0.2.1-rc.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.1`)。
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.1
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.1
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.1`**(实机验证版本:DSH Desktop `0.2.1-rc.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, withPending } = model
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: target })
4144
+ const value = await callAction('sftp/list', { host: host.host, path: normalized })
4106
4145
  setEntries(value.entries ?? [])
4107
- setPath(target)
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
- void loadDirectory('.')
4119
- }, [loadDirectory])
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
- entry.directory
4224
- ? h('span', null)
4225
- : h(
4226
- IconButton,
4227
- { title: t('common.download'), onClick: () => void downloadEntry(entry) },
4228
- h(IconDownload, { size: 14 }),
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(TextInput, {
4563
- value: draft.group,
4564
- onChange: (value) => update('group', value),
4565
- placeholder: t('editor.groupPlaceholder'),
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-3);font-variant-numeric:tabular-nums}
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-3);font-size:11px;font-weight:500;background:var(--ssh-bg);position:sticky;top:0;z-index:3;min-width:640px}
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-dim)}
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-dim)}
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(--dsw-alias-state-success-label,var(--ssh-text-2))}
5152
- .ssh-latency.muted{color:var(--ssh-dim)}
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-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
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-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
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-dim)}
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-dim)}
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 28px;align-items:center;gap:8px;padding:0 10px;height:34px;border-bottom:.5px solid var(--ssh-border);font-size:12px}
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) => ({ name: entry.filename, size: entry.attrs?.size ?? 0, mode: entry.attrs?.mode ?? 0, directory: Boolean(entry.attrs?.isDirectory?.()) })))
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 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ssh-manage",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "A DSH sidebar plugin for managing SSH host profiles with a real interactive PTY terminal.",
5
5
  "license": "MIT",
6
6
  "private": false,