dsh-ssh-manage 0.3.0 → 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/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
 
@@ -28,9 +28,10 @@ const ASSETS = [
28
28
  { path: '/dsh-ssh-manage/assets/xterm.js', file: 'xterm.js', contentType: 'text/javascript; charset=utf-8' },
29
29
  { path: '/dsh-ssh-manage/assets/xterm.css', file: 'xterm.css', contentType: 'text/css; charset=utf-8' },
30
30
  { path: '/dsh-ssh-manage/assets/addon-fit.js', file: 'addon-fit.js', contentType: 'text/javascript; charset=utf-8' },
31
+ { path: '/dsh-ssh-manage/assets/addon-search.js', file: 'addon-search.js', contentType: 'text/javascript; charset=utf-8' },
31
32
  ]
32
33
  const MAX_BODY_BYTES = 16 * 1024 * 1024
33
- 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']
34
35
 
35
36
  async function readJsonBody(req) {
36
37
  const chunks = []
@@ -216,6 +217,18 @@ export async function runAction(paths, action, params = {}) {
216
217
  const host = await findHost(paths, params.host)
217
218
  return await downloadFile(host, await requirePassword(paths, host), requireString(params, 'path'))
218
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
+ }
219
232
  default:
220
233
  throw new Error(`Unknown action: ${String(action)}`)
221
234
  }
@@ -240,6 +253,8 @@ function managerTool(paths) {
240
253
  command: { type: 'string', description: 'Remote shell command.' },
241
254
  hosts: { type: 'array', items: { type: 'string' }, description: 'Host aliases for a batch command.' },
242
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).' },
243
258
  contentBase64: { type: 'string', description: 'Base64 encoded file content for upload.' },
244
259
  metadata: { type: 'object', description: '{ group, tags, favorite, label, note, system, cpuCores, memoryGB, diskGB, latencyMs, checkedAt }' },
245
260
  },
@@ -283,6 +298,12 @@ function managerTool(paths) {
283
298
  return await runAction(paths, 'sftp/upload', params)
284
299
  case 'sftp_download':
285
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)
286
307
  default:
287
308
  throw new Error(`Unknown action: ${String(input.action)}`)
288
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.0",
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,