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 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.2`).
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.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.1
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.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.2`)。
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.2
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.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.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
@@ -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, withPending } = model
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: target })
4141
+ const value = await callAction('sftp/list', { host: host.host, path: normalized })
4106
4142
  setEntries(value.entries ?? [])
4107
- setPath(target)
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
- void loadDirectory('.')
4119
- }, [loadDirectory])
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
- 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
- ),
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 28px;align-items:center;gap:8px;padding:0 10px;height:34px;border-bottom:.5px solid var(--ssh-border);font-size:12px}
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) => ({ 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.2",
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,