dsh-remote 0.8.71 → 0.8.73

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/lib/remote-fs.js CHANGED
@@ -1,173 +1,173 @@
1
- // Shared remote SFTP helpers used by both rw_* tools and the sidebar HTTP routes.
2
- import { joinRemotePath, normalizeRemotePath } from './paths.js'
3
-
4
- /** Structured listing: name + type + size + mtime + mode (SFTP protocol-level). */
5
- export async function listDirStructured(sftp, p) {
6
- const target = normalizeRemotePath(p || '/')
7
- let list
8
- try {
9
- list = await sftp.readdir(target)
10
- } catch (err) {
11
- const msg = String((err && err.message) || err)
12
- if (/no such file|not found|does not exist|ENOENT/i.test(msg)) {
13
- return { path: target, items: [], missing: true }
14
- }
15
- throw new Error('browse failed: ' + msg)
16
- }
17
- const items = []
18
- const symIdx = []
19
- for (const e of list) {
20
- const name = String(e.filename)
21
- if (name === '.' || name === '..' || !name) continue
22
- const a = e.attrs || {}
23
- let type
24
- if (a.isSymbolicLink && a.isSymbolicLink()) {
25
- type = 'symlink'
26
- symIdx.push(items.length)
27
- } else if (a.isDirectory && a.isDirectory()) {
28
- type = 'dir'
29
- } else {
30
- type = 'file'
31
- }
32
- items.push({
33
- type,
34
- name,
35
- size: typeof a.size === 'number' ? a.size : 0,
36
- mtime: typeof a.mtime === 'number' ? a.mtime : 0,
37
- mode: typeof a.mode === 'number' ? a.mode.toString(8) : '',
38
- })
39
- }
40
- if (symIdx.length) {
41
- // Resolve what each symlink POINTS TO. This must be stat (follows links),
42
- // not lstat: lstat describes the link itself, so a symlink to a directory
43
- // was misclassified as 'file' and the workspace picker refused to enter
44
- // it (issue #59). A dangling link fails stat and degrades to 'file'.
45
- await Promise.all(symIdx.map(async (i) => {
46
- const full = joinRemotePath(target, items[i].name)
47
- try {
48
- const st = await sftp.stat(full)
49
- items[i].type = st && st.isDirectory && st.isDirectory() ? 'dir' : 'file'
50
- items[i].link = true
51
- } catch { items[i].type = 'file' /* dangling link: degrade to file */ }
52
- }))
53
- }
54
- return { path: target, items }
55
- }
56
-
57
- /** Windows drive letters as display-form dir entries ("This PC" root view). */
58
- export async function listRemoteDrives(sshPool) {
59
- let letters = []
60
- try {
61
- const res = await sshPool.exec('cmd /c fsutil fsinfo drives', { timeoutMs: Math.min(sshPool.config.commandTimeoutMs || 8000, 8000) })
62
- letters = [...String(res.stdout || '').matchAll(/([a-zA-Z]):\\?/g)].map((mm) => mm[1].toUpperCase())
63
- } catch {}
64
- if (!letters.length) {
65
- try {
66
- const res = await sshPool.exec('ls -d /[a-z] 2>/dev/null', { timeoutMs: Math.min(sshPool.config.commandTimeoutMs || 8000, 8000) })
67
- letters = String(res.stdout || '')
68
- .split(/\s+/)
69
- .map((s) => s.replace(/^\/+|\/+$/g, ''))
70
- .filter((s) => /^[a-zA-Z]$/.test(s))
71
- .map((s) => s.toUpperCase())
72
- } catch {}
73
- }
74
- return [...new Set(letters)].sort().map((l) => ({ name: l + ':\\', path: l + ':\\', type: 'dir', drive: true, size: 0, mtime: 0 }))
75
- }
76
-
77
- /** Recursive remote delete (bounded): unlink files bottom-up, then rmdir. */
78
- export async function removeRemoteTree(sftp, p, maxFiles = 2000) {
79
- let removed = 0
80
- const walk = async (dir) => {
81
- if (removed >= maxFiles) return
82
- let entries = []
83
- try { entries = (await sftp.readdir(dir)) || [] } catch { return }
84
- for (const e of entries) {
85
- if (removed >= maxFiles) return
86
- const name = String(e.filename)
87
- if (name === '.' || name === '..') continue
88
- const fp = joinRemotePath(dir, name)
89
- const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
90
- if (isDir) await walk(fp)
91
- else {
92
- try { await sftp.unlink(fp); removed++ } catch {}
93
- }
94
- }
95
- try { await sftp.rmdir(dir); removed++ } catch { /* already gone or non-empty */ }
96
- }
97
- await walk(p)
98
- return removed
99
- }
100
-
101
- /** MIME types for image formats the preview panel can render inline. */
102
- const IMAGE_MIME_TYPES = {
103
- png: 'image/png',
104
- jpg: 'image/jpeg',
105
- jpeg: 'image/jpeg',
106
- gif: 'image/gif',
107
- webp: 'image/webp',
108
- svg: 'image/svg+xml',
109
- bmp: 'image/bmp',
110
- }
111
-
112
- function looksBinary(buf) {
113
- const n = Math.min(buf.length, 8192)
114
- for (let i = 0; i < n; i++) if (buf[i] === 0) return true
115
- return false
116
- }
117
-
118
- /**
119
- * Read a remote file for the sidebar/editor. Oversized files use a range read
120
- * (`readPartial`) so we never `fastGet` the whole blob onto disk.
121
- *
122
- * Images are the exception (issue #66): a PREFIX of an image is a broken
123
- * picture, not a preview, so for a recognised image extension the caller's
124
- * text-oriented cap is skipped and the file is read whole up to the hard 2MB
125
- * ceiling; the panel renders it inline as `<img src="data:…">`. An image past
126
- * the ceiling degrades to the plain binary stub + download button rather than
127
- * shipping a guaranteed-broken half render. (`<img>` is also the safe sink
128
- * for SVG: unlike inline markup it cannot run the file's scripts.)
129
- */
130
- export async function previewRemoteFile(sftp, target, opts = {}) {
131
- const {
132
- maxBytes = 256 * 1024,
133
- encoding = 'utf-8',
134
- decodeBuf = (buf) => buf.toString('utf8'),
135
- } = opts
136
- const hardCap = 2 * 1024 * 1024
137
- const name = String(target).split(/[\\/]/).pop() || ''
138
- const imageMime = IMAGE_MIME_TYPES[(name.split('.').pop() || '').toLowerCase()]
139
- const cap = imageMime ? hardCap : Math.min(Math.max(Number(maxBytes) || 256 * 1024, 1024), hardCap)
140
- const st = await sftp.stat(target)
141
- const mtime = st && typeof st.mtime === 'number' ? st.mtime : 0
142
- const size = st && typeof st.size === 'number' ? st.size : 0
143
- let buf
144
- let truncated = false
145
- if (size > cap) {
146
- truncated = true
147
- if (typeof sftp.readPartial === 'function') {
148
- buf = await sftp.readPartial(target, 0, cap)
149
- } else {
150
- const full = await sftp.readFile(target)
151
- buf = full.subarray(0, cap)
152
- }
153
- } else {
154
- buf = await sftp.readFile(target)
155
- }
156
- if (imageMime && !truncated) {
157
- return { ok: true, binary: false, image: true, mime: imageMime, content: buf.toString('base64'), truncated, size, mtime }
158
- }
159
- if (looksBinary(buf)) {
160
- return {
161
- ok: true, binary: true, size, mtime,
162
- head: buf.slice(0, Math.min(buf.length, 4096)).toString('base64'),
163
- truncated,
164
- }
165
- }
166
- let content = decodeBuf(buf, encoding).replace(/\r\n/g, '\n')
167
- if (!truncated && content.length > cap) {
168
- truncated = true
169
- content = content.slice(0, cap)
170
- }
171
- if (truncated) content += `\n…[truncated: ${Math.max(0, size - cap)} more bytes]`
172
- return { ok: true, binary: false, content, truncated, size, mtime }
173
- }
1
+ // Shared remote SFTP helpers used by both rw_* tools and the sidebar HTTP routes.
2
+ import { joinRemotePath, normalizeRemotePath } from './paths.js'
3
+
4
+ /** Structured listing: name + type + size + mtime + mode (SFTP protocol-level). */
5
+ export async function listDirStructured(sftp, p) {
6
+ const target = normalizeRemotePath(p || '/')
7
+ let list
8
+ try {
9
+ list = await sftp.readdir(target)
10
+ } catch (err) {
11
+ const msg = String((err && err.message) || err)
12
+ if (/no such file|not found|does not exist|ENOENT/i.test(msg)) {
13
+ return { path: target, items: [], missing: true }
14
+ }
15
+ throw new Error('browse failed: ' + msg)
16
+ }
17
+ const items = []
18
+ const symIdx = []
19
+ for (const e of list) {
20
+ const name = String(e.filename)
21
+ if (name === '.' || name === '..' || !name) continue
22
+ const a = e.attrs || {}
23
+ let type
24
+ if (a.isSymbolicLink && a.isSymbolicLink()) {
25
+ type = 'symlink'
26
+ symIdx.push(items.length)
27
+ } else if (a.isDirectory && a.isDirectory()) {
28
+ type = 'dir'
29
+ } else {
30
+ type = 'file'
31
+ }
32
+ items.push({
33
+ type,
34
+ name,
35
+ size: typeof a.size === 'number' ? a.size : 0,
36
+ mtime: typeof a.mtime === 'number' ? a.mtime : 0,
37
+ mode: typeof a.mode === 'number' ? a.mode.toString(8) : '',
38
+ })
39
+ }
40
+ if (symIdx.length) {
41
+ // Resolve what each symlink POINTS TO. This must be stat (follows links),
42
+ // not lstat: lstat describes the link itself, so a symlink to a directory
43
+ // was misclassified as 'file' and the workspace picker refused to enter
44
+ // it (issue #59). A dangling link fails stat and degrades to 'file'.
45
+ await Promise.all(symIdx.map(async (i) => {
46
+ const full = joinRemotePath(target, items[i].name)
47
+ try {
48
+ const st = await sftp.stat(full)
49
+ items[i].type = st && st.isDirectory && st.isDirectory() ? 'dir' : 'file'
50
+ items[i].link = true
51
+ } catch { items[i].type = 'file' /* dangling link: degrade to file */ }
52
+ }))
53
+ }
54
+ return { path: target, items }
55
+ }
56
+
57
+ /** Windows drive letters as display-form dir entries ("This PC" root view). */
58
+ export async function listRemoteDrives(sshPool) {
59
+ let letters = []
60
+ try {
61
+ const res = await sshPool.exec('cmd /c fsutil fsinfo drives', { timeoutMs: Math.min(sshPool.config.commandTimeoutMs || 8000, 8000) })
62
+ letters = [...String(res.stdout || '').matchAll(/([a-zA-Z]):\\?/g)].map((mm) => mm[1].toUpperCase())
63
+ } catch {}
64
+ if (!letters.length) {
65
+ try {
66
+ const res = await sshPool.exec('ls -d /[a-z] 2>/dev/null', { timeoutMs: Math.min(sshPool.config.commandTimeoutMs || 8000, 8000) })
67
+ letters = String(res.stdout || '')
68
+ .split(/\s+/)
69
+ .map((s) => s.replace(/^\/+|\/+$/g, ''))
70
+ .filter((s) => /^[a-zA-Z]$/.test(s))
71
+ .map((s) => s.toUpperCase())
72
+ } catch {}
73
+ }
74
+ return [...new Set(letters)].sort().map((l) => ({ name: l + ':\\', path: l + ':\\', type: 'dir', drive: true, size: 0, mtime: 0 }))
75
+ }
76
+
77
+ /** Recursive remote delete (bounded): unlink files bottom-up, then rmdir. */
78
+ export async function removeRemoteTree(sftp, p, maxFiles = 2000) {
79
+ let removed = 0
80
+ const walk = async (dir) => {
81
+ if (removed >= maxFiles) return
82
+ let entries = []
83
+ try { entries = (await sftp.readdir(dir)) || [] } catch { return }
84
+ for (const e of entries) {
85
+ if (removed >= maxFiles) return
86
+ const name = String(e.filename)
87
+ if (name === '.' || name === '..') continue
88
+ const fp = joinRemotePath(dir, name)
89
+ const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
90
+ if (isDir) await walk(fp)
91
+ else {
92
+ try { await sftp.unlink(fp); removed++ } catch {}
93
+ }
94
+ }
95
+ try { await sftp.rmdir(dir); removed++ } catch { /* already gone or non-empty */ }
96
+ }
97
+ await walk(p)
98
+ return removed
99
+ }
100
+
101
+ /** MIME types for image formats the preview panel can render inline. */
102
+ const IMAGE_MIME_TYPES = {
103
+ png: 'image/png',
104
+ jpg: 'image/jpeg',
105
+ jpeg: 'image/jpeg',
106
+ gif: 'image/gif',
107
+ webp: 'image/webp',
108
+ svg: 'image/svg+xml',
109
+ bmp: 'image/bmp',
110
+ }
111
+
112
+ function looksBinary(buf) {
113
+ const n = Math.min(buf.length, 8192)
114
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true
115
+ return false
116
+ }
117
+
118
+ /**
119
+ * Read a remote file for the sidebar/editor. Oversized files use a range read
120
+ * (`readPartial`) so we never `fastGet` the whole blob onto disk.
121
+ *
122
+ * Images are the exception (issue #66): a PREFIX of an image is a broken
123
+ * picture, not a preview, so for a recognised image extension the caller's
124
+ * text-oriented cap is skipped and the file is read whole up to the hard 2MB
125
+ * ceiling; the panel renders it inline as `<img src="data:…">`. An image past
126
+ * the ceiling degrades to the plain binary stub + download button rather than
127
+ * shipping a guaranteed-broken half render. (`<img>` is also the safe sink
128
+ * for SVG: unlike inline markup it cannot run the file's scripts.)
129
+ */
130
+ export async function previewRemoteFile(sftp, target, opts = {}) {
131
+ const {
132
+ maxBytes = 256 * 1024,
133
+ encoding = 'utf-8',
134
+ decodeBuf = (buf) => buf.toString('utf8'),
135
+ } = opts
136
+ const hardCap = 2 * 1024 * 1024
137
+ const name = String(target).split(/[\\/]/).pop() || ''
138
+ const imageMime = IMAGE_MIME_TYPES[(name.split('.').pop() || '').toLowerCase()]
139
+ const cap = imageMime ? hardCap : Math.min(Math.max(Number(maxBytes) || 256 * 1024, 1024), hardCap)
140
+ const st = await sftp.stat(target)
141
+ const mtime = st && typeof st.mtime === 'number' ? st.mtime : 0
142
+ const size = st && typeof st.size === 'number' ? st.size : 0
143
+ let buf
144
+ let truncated = false
145
+ if (size > cap) {
146
+ truncated = true
147
+ if (typeof sftp.readPartial === 'function') {
148
+ buf = await sftp.readPartial(target, 0, cap)
149
+ } else {
150
+ const full = await sftp.readFile(target)
151
+ buf = full.subarray(0, cap)
152
+ }
153
+ } else {
154
+ buf = await sftp.readFile(target)
155
+ }
156
+ if (imageMime && !truncated) {
157
+ return { ok: true, binary: false, image: true, mime: imageMime, content: buf.toString('base64'), truncated, size, mtime }
158
+ }
159
+ if (looksBinary(buf)) {
160
+ return {
161
+ ok: true, binary: true, size, mtime,
162
+ head: buf.slice(0, Math.min(buf.length, 4096)).toString('base64'),
163
+ truncated,
164
+ }
165
+ }
166
+ let content = decodeBuf(buf, encoding).replace(/\r\n/g, '\n')
167
+ if (!truncated && content.length > cap) {
168
+ truncated = true
169
+ content = content.slice(0, cap)
170
+ }
171
+ if (truncated) content += `\n…[truncated: ${Math.max(0, size - cap)} more bytes]`
172
+ return { ok: true, binary: false, content, truncated, size, mtime }
173
+ }