dsh-remote 0.8.17 → 0.8.20
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.md +40 -63
- package/README.zh.md +40 -47
- package/cordis.patch.yml +5 -55
- package/lib/binding.js +69 -0
- package/lib/client.js +369 -136
- package/lib/hostkey.js +60 -0
- package/lib/index.js +104 -924
- package/lib/pool.js +511 -0
- package/lib/remote-fs.js +143 -0
- package/lib/routes-fs.js +221 -0
- package/lib/search.js +59 -1
- package/lib/sync.js +15 -10
- package/package.json +2 -3
package/lib/routes-fs.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Sidebar file routes: /ls /read /write /fs.
|
|
2
|
+
//
|
|
3
|
+
// Machine-scoped callers (workspace picker, settings) omit sessionId and use
|
|
4
|
+
// the active-machine pool. Session-scoped callers (explorer / file tab) MUST
|
|
5
|
+
// pass sessionId (or local=mirror cwd); those requests resolve through the
|
|
6
|
+
// same mirror binding as rw_* tools and never fall back to the active machine.
|
|
7
|
+
import { mkdirSync } from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import {
|
|
10
|
+
normalizeRemotePath, joinRemotePath, remoteDirname, mkdirRemoteDirs, toDisplayPath,
|
|
11
|
+
} from './paths.js'
|
|
12
|
+
import { requestSessionHint } from './binding.js'
|
|
13
|
+
import { listDirStructured, listRemoteDrives, removeRemoteTree, previewRemoteFile } from './remote-fs.js'
|
|
14
|
+
|
|
15
|
+
export function createFsRoutes({
|
|
16
|
+
sendJson,
|
|
17
|
+
readBody,
|
|
18
|
+
resolveRequestBinding,
|
|
19
|
+
decodeBuf,
|
|
20
|
+
encodeText,
|
|
21
|
+
audit,
|
|
22
|
+
config,
|
|
23
|
+
mirrorDirFor,
|
|
24
|
+
}) {
|
|
25
|
+
const bindOrReply = async (req, res, body = {}) => {
|
|
26
|
+
try {
|
|
27
|
+
return await resolveRequestBinding(req, body)
|
|
28
|
+
} catch (err) {
|
|
29
|
+
sendJson(res, (err && err.httpStatus) || 500, { ok: false, error: String((err && err.message) || err) })
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
kind: 'exact',
|
|
37
|
+
path: '/dsh-remote/ls',
|
|
38
|
+
handler: async (req, res) => {
|
|
39
|
+
try {
|
|
40
|
+
const hint = requestSessionHint(req)
|
|
41
|
+
const b = await bindOrReply(req, res, hint)
|
|
42
|
+
if (!b) return
|
|
43
|
+
await b.pool.detect()
|
|
44
|
+
const q = new URL(req.url, 'http://localhost').searchParams
|
|
45
|
+
const raw = q.get('path') ? decodeURIComponent(q.get('path')) : (b.ws || '')
|
|
46
|
+
const canon = normalizeRemotePath(raw)
|
|
47
|
+
const win = b.pool.platform === 'windows'
|
|
48
|
+
if (canon === '/' || raw === '' || raw === '/') {
|
|
49
|
+
if (win || b.pool.platform !== 'posix') {
|
|
50
|
+
const drives = await listRemoteDrives(b.pool)
|
|
51
|
+
if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives, bound: b.bound })
|
|
52
|
+
}
|
|
53
|
+
if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [], bound: b.bound })
|
|
54
|
+
}
|
|
55
|
+
const sftp = await b.pool.sftp()
|
|
56
|
+
const out = await listDirStructured(sftp, canon)
|
|
57
|
+
const items = out.items.map((it) => ({
|
|
58
|
+
...it,
|
|
59
|
+
path: toDisplayPath(joinRemotePath(canon, it.name), b.pool.platform),
|
|
60
|
+
}))
|
|
61
|
+
return sendJson(res, 200, {
|
|
62
|
+
path: toDisplayPath(out.path, b.pool.platform),
|
|
63
|
+
platform: win ? 'windows' : 'posix',
|
|
64
|
+
items,
|
|
65
|
+
missing: !!out.missing,
|
|
66
|
+
bound: b.bound,
|
|
67
|
+
})
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
kind: 'exact',
|
|
75
|
+
path: '/dsh-remote/read',
|
|
76
|
+
handler: async (req, res) => {
|
|
77
|
+
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
78
|
+
try {
|
|
79
|
+
const q = new URL(req.url, 'http://localhost').searchParams
|
|
80
|
+
let p = q.get('path') ? decodeURIComponent(q.get('path')) : ''
|
|
81
|
+
let encoding = ''
|
|
82
|
+
let maxBytes = 256 * 1024
|
|
83
|
+
let body = {}
|
|
84
|
+
if (req.method === 'POST') {
|
|
85
|
+
body = JSON.parse((await readBody(req)) || '{}')
|
|
86
|
+
if (!p) p = String(body.path || '')
|
|
87
|
+
if (body.encoding) encoding = String(body.encoding)
|
|
88
|
+
if (body.maxBytes) maxBytes = Number(body.maxBytes)
|
|
89
|
+
}
|
|
90
|
+
if (q.get('maxBytes')) maxBytes = Number(q.get('maxBytes'))
|
|
91
|
+
if (!p) return sendJson(res, 400, { ok: false, error: 'path is required' })
|
|
92
|
+
const b = await bindOrReply(req, res, body)
|
|
93
|
+
if (!b) return
|
|
94
|
+
const target = normalizeRemotePath(p)
|
|
95
|
+
let sftp
|
|
96
|
+
try {
|
|
97
|
+
sftp = await b.pool.sftp()
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return sendJson(res, 500, { ok: false, error: 'sftp unavailable: ' + ((err && err.message) || err) })
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const preview = await previewRemoteFile(sftp, target, {
|
|
103
|
+
maxBytes,
|
|
104
|
+
encoding: encoding || config.encoding,
|
|
105
|
+
decodeBuf,
|
|
106
|
+
})
|
|
107
|
+
return sendJson(res, 200, preview)
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
kind: 'exact',
|
|
118
|
+
path: '/dsh-remote/write',
|
|
119
|
+
handler: async (req, res) => {
|
|
120
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
121
|
+
try {
|
|
122
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
123
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
124
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path is required' })
|
|
125
|
+
const b = await bindOrReply(req, res, body)
|
|
126
|
+
if (!b) return
|
|
127
|
+
const sftp = await b.pool.sftp()
|
|
128
|
+
let st = null
|
|
129
|
+
try { st = await sftp.stat(p) } catch { /* new file */ }
|
|
130
|
+
if (body.expectedMtime != null && st && st.mtime !== Number(body.expectedMtime)) {
|
|
131
|
+
return sendJson(res, 409, { ok: false, error: `远端文件已变化(mtime ${st.mtime} ≠ ${body.expectedMtime}),已放弃保存——请重新读取后再编辑` })
|
|
132
|
+
}
|
|
133
|
+
const buf = encodeText(String(body.content ?? ''), body.encoding || config.encoding)
|
|
134
|
+
if (!st) await mkdirRemoteDirs(sftp, remoteDirname(p))
|
|
135
|
+
await sftp.writeFile(p, buf)
|
|
136
|
+
audit('write', `write ${p}`, 0, b)
|
|
137
|
+
const st2 = await sftp.stat(p).catch(() => null)
|
|
138
|
+
return sendJson(res, 200, { ok: true, bytes: buf.byteLength, mtime: st2 ? st2.mtime : Math.floor(Date.now() / 1000) })
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
kind: 'exact',
|
|
146
|
+
path: '/dsh-remote/fs',
|
|
147
|
+
handler: async (req, res) => {
|
|
148
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
149
|
+
try {
|
|
150
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
151
|
+
const op = String(body.op || '')
|
|
152
|
+
const b = await bindOrReply(req, res, body)
|
|
153
|
+
if (!b) return
|
|
154
|
+
const sftp = await b.pool.sftp()
|
|
155
|
+
if (op === 'mkdir') {
|
|
156
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
157
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
|
|
158
|
+
await mkdirRemoteDirs(sftp, p)
|
|
159
|
+
audit('mkdir', `mkdir ${p}`, 0, b)
|
|
160
|
+
return sendJson(res, 200, { ok: true })
|
|
161
|
+
}
|
|
162
|
+
if (op === 'rename') {
|
|
163
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
164
|
+
const d = normalizeRemotePath(String(body.dest || ''))
|
|
165
|
+
if (!p || !d) return sendJson(res, 400, { ok: false, error: 'path and dest required' })
|
|
166
|
+
await sftp.rename(p, d)
|
|
167
|
+
audit('move', `move ${p} → ${d}`, 0, b)
|
|
168
|
+
return sendJson(res, 200, { ok: true })
|
|
169
|
+
}
|
|
170
|
+
if (op === 'remove') {
|
|
171
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
172
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
|
|
173
|
+
const st = await sftp.stat(p).catch(() => null)
|
|
174
|
+
if (!st) return sendJson(res, 404, { ok: false, error: 'not found' })
|
|
175
|
+
if (st.isDirectory && st.isDirectory()) {
|
|
176
|
+
await removeRemoteTree(sftp, p)
|
|
177
|
+
} else {
|
|
178
|
+
await sftp.unlink(p)
|
|
179
|
+
}
|
|
180
|
+
audit('remove', `remove ${p}`, 0, b)
|
|
181
|
+
return sendJson(res, 200, { ok: true })
|
|
182
|
+
}
|
|
183
|
+
if (op === 'write' || op === 'append') {
|
|
184
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
185
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
|
|
186
|
+
let content = ''
|
|
187
|
+
if (op === 'append') {
|
|
188
|
+
try { content = decodeBuf(await sftp.readFile(p), body.encoding || config.encoding) } catch { /* new */ }
|
|
189
|
+
}
|
|
190
|
+
const buf = encodeText(content + String(body.content ?? ''), body.encoding || config.encoding)
|
|
191
|
+
if (op === 'write' && !(await sftp.stat(p).catch(() => null))) await mkdirRemoteDirs(sftp, remoteDirname(p))
|
|
192
|
+
await sftp.writeFile(p, buf)
|
|
193
|
+
audit(op, `${op} ${p}`, 0, b)
|
|
194
|
+
return sendJson(res, 200, { ok: true, bytes: buf.byteLength })
|
|
195
|
+
}
|
|
196
|
+
if (op === 'download') {
|
|
197
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
198
|
+
const ws = b.ws
|
|
199
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
|
|
200
|
+
if (!ws) return sendJson(res, 400, { ok: false, error: 'no remote workspace set' })
|
|
201
|
+
const st = await sftp.stat(p).catch(() => null)
|
|
202
|
+
if (!st) return sendJson(res, 404, { ok: false, error: 'not found' })
|
|
203
|
+
if (config.maxFileBytes > 0 && st.size > config.maxFileBytes) {
|
|
204
|
+
return sendJson(res, 400, { ok: false, error: `file is ${st.size} bytes (over ${config.maxFileBytes} cap)` })
|
|
205
|
+
}
|
|
206
|
+
const base = b.mirrorDir || mirrorDirFor(ws, b.host, b.username, b.port)
|
|
207
|
+
const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
|
|
208
|
+
const local = path.join(base, rel)
|
|
209
|
+
mkdirSync(path.dirname(local), { recursive: true })
|
|
210
|
+
await sftp.fastGet(p, local)
|
|
211
|
+
audit('download', `download ${p}`, 0, b)
|
|
212
|
+
return sendJson(res, 200, { ok: true, bytes: st.size, local })
|
|
213
|
+
}
|
|
214
|
+
return sendJson(res, 400, { ok: false, error: 'unknown op' })
|
|
215
|
+
} catch (err) {
|
|
216
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
]
|
|
221
|
+
}
|
package/lib/search.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Replaces `find … -exec grep` (which needs a POSIX shell and dies on
|
|
3
3
|
// Windows remotes). Walks the remote tree over SFTP readdir/stat/readFile with
|
|
4
4
|
// the same bounded-parallelism + ignore rules as the mirror sync.
|
|
5
|
-
import { joinRemotePath } from './paths.js'
|
|
5
|
+
import { joinRemotePath, shq } from './paths.js'
|
|
6
6
|
|
|
7
7
|
function mapLimit(items, limit, fn) {
|
|
8
8
|
const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
|
|
@@ -123,5 +123,63 @@ export async function searchTree(sftp, rootDir, opts = {}) {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
await walk(rootDir, maxDepth)
|
|
126
|
+
if (scanned >= maxFiles) truncated = true
|
|
126
127
|
return { matches, scanned, truncated }
|
|
127
128
|
}
|
|
129
|
+
|
|
130
|
+
function parseGrepLines(stdout, maxMatches) {
|
|
131
|
+
const matches = []
|
|
132
|
+
let truncated = false
|
|
133
|
+
for (const line of String(stdout || '').split('\n')) {
|
|
134
|
+
if (!line) continue
|
|
135
|
+
const m = line.match(/^(.*?):(\d+):(.*)$/)
|
|
136
|
+
if (!m) continue
|
|
137
|
+
if (matches.length >= maxMatches) { truncated = true; break }
|
|
138
|
+
matches.push({ path: m[1], line: Number(m[2]), text: m[3] })
|
|
139
|
+
}
|
|
140
|
+
return { matches, truncated }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* POSIX fast path: `rg` then `grep -R`. Returns null when the remote has
|
|
145
|
+
* neither, so the caller can fall back to an SFTP walk (Windows / no grep).
|
|
146
|
+
*/
|
|
147
|
+
export async function searchViaShell(pool, rootDir, opts = {}) {
|
|
148
|
+
const {
|
|
149
|
+
pattern,
|
|
150
|
+
ignoreCase = true,
|
|
151
|
+
glob,
|
|
152
|
+
contextLines = 0,
|
|
153
|
+
maxMatches = 500,
|
|
154
|
+
} = opts
|
|
155
|
+
const ic = ignoreCase ? ' -i' : ''
|
|
156
|
+
const globFlag = glob ? ` --glob ${shq(glob)}` : ''
|
|
157
|
+
const ctx = contextLines > 0 ? ` -C ${Number(contextLines)}` : ''
|
|
158
|
+
const rg = `rg -n --no-heading -I${ic}${globFlag}${ctx} --max-count ${maxMatches} -- ${shq(pattern)} ${shq(rootDir)}`
|
|
159
|
+
const grepGlob = glob ? ` --include=${shq(glob)}` : ''
|
|
160
|
+
const grep = `grep -R -n -I -E${ic}${grepGlob}${ctx} -- ${shq(pattern)} ${shq(rootDir)}`
|
|
161
|
+
const script = `if command -v rg >/dev/null 2>&1; then ${rg}; elif command -v grep >/dev/null 2>&1; then ${grep}; else exit 127; fi`
|
|
162
|
+
const res = await pool.exec(script, { timeoutMs: opts.timeoutMs })
|
|
163
|
+
// 0 = hits, 1 = no hits; other codes mean the tools are missing / failed.
|
|
164
|
+
if (res.signal === 'TIMEOUT') throw new Error('search timed out')
|
|
165
|
+
if (res.code !== 0 && res.code !== 1) throw new Error(res.stderr || `search exit ${res.code}`)
|
|
166
|
+
const parsed = parseGrepLines(res.stdout, maxMatches)
|
|
167
|
+
return {
|
|
168
|
+
matches: parsed.matches,
|
|
169
|
+
scanned: parsed.matches.length,
|
|
170
|
+
truncated: parsed.truncated || /truncated|max-count/i.test(String(res.stderr || '')),
|
|
171
|
+
via: /rg/.test(String(res.stderr || '')) ? 'rg' : 'grep',
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Prefer a POSIX grep/rg, fall back to the portable SFTP walk. */
|
|
176
|
+
export async function searchRemote(pool, rootDir, opts = {}) {
|
|
177
|
+
if (pool && pool.platform !== 'windows') {
|
|
178
|
+
try {
|
|
179
|
+
const fast = await searchViaShell(pool, rootDir, opts)
|
|
180
|
+
if (fast && Array.isArray(fast.matches)) return fast
|
|
181
|
+
} catch { /* missing rg/grep or Windows-like remote → SFTP walk */ }
|
|
182
|
+
}
|
|
183
|
+
const sftp = await pool.sftp()
|
|
184
|
+
return searchTree(sftp, rootDir, opts)
|
|
185
|
+
}
|
package/lib/sync.js
CHANGED
|
@@ -43,15 +43,15 @@ const remoteKey = (st) => (st ? keyOf(st.size, st.mtime) : null)
|
|
|
43
43
|
/** Recursively pull remote → local mirror (three-way, ignore-aware). */
|
|
44
44
|
export async function syncTree(sftp, remoteDir, localDir, opts = {}) {
|
|
45
45
|
const {
|
|
46
|
-
maxDepth =
|
|
47
|
-
maxFiles =
|
|
46
|
+
maxDepth = 8,
|
|
47
|
+
maxFiles = 2000,
|
|
48
48
|
maxFileBytes = 0,
|
|
49
49
|
isIgnored = () => false,
|
|
50
50
|
dryRun = false,
|
|
51
51
|
force = false,
|
|
52
52
|
state = {},
|
|
53
53
|
} = opts
|
|
54
|
-
const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleRemote: 0, touched: [] }
|
|
54
|
+
const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleRemote: 0, touched: [], truncated: false }
|
|
55
55
|
const nextState = { ...state }
|
|
56
56
|
|
|
57
57
|
const walk = async (rDir, lDir, depth) => {
|
|
@@ -62,7 +62,10 @@ export async function syncTree(sftp, remoteDir, localDir, opts = {}) {
|
|
|
62
62
|
if (name === '.' || name === '..') continue
|
|
63
63
|
const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
|
|
64
64
|
if (!isDir) continue
|
|
65
|
-
if (depth <= 0 || stats.files >= maxFiles)
|
|
65
|
+
if (depth <= 0 || stats.files >= maxFiles) {
|
|
66
|
+
stats.truncated = true
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
66
69
|
const rp = joinRemotePath(rDir, name)
|
|
67
70
|
const lp = path.join(lDir, name)
|
|
68
71
|
const rel = relPathUnder(remoteDir, rp) || name
|
|
@@ -72,14 +75,14 @@ export async function syncTree(sftp, remoteDir, localDir, opts = {}) {
|
|
|
72
75
|
stats.dirs++
|
|
73
76
|
if (stats.files >= maxFiles) break
|
|
74
77
|
}
|
|
75
|
-
if (stats.files >= maxFiles) return
|
|
78
|
+
if (stats.files >= maxFiles) { stats.truncated = true; return }
|
|
76
79
|
|
|
77
80
|
const files = entries.filter(
|
|
78
81
|
(e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
|
|
79
82
|
String(e.filename) !== '.' && String(e.filename) !== '..',
|
|
80
83
|
)
|
|
81
84
|
await mapLimit(files, 4, async (e) => {
|
|
82
|
-
if (stats.files >= maxFiles) return
|
|
85
|
+
if (stats.files >= maxFiles) { stats.truncated = true; return }
|
|
83
86
|
const name = String(e.filename)
|
|
84
87
|
const rp = joinRemotePath(rDir, name)
|
|
85
88
|
const lp = path.join(lDir, name)
|
|
@@ -122,6 +125,7 @@ export async function syncTree(sftp, remoteDir, localDir, opts = {}) {
|
|
|
122
125
|
}
|
|
123
126
|
|
|
124
127
|
await walk(remoteDir, localDir, maxDepth)
|
|
128
|
+
if (stats.files >= maxFiles) stats.truncated = true
|
|
125
129
|
// Informational: remote entries in the snapshot that no longer exist remotely.
|
|
126
130
|
for (const rel of Object.keys(state)) {
|
|
127
131
|
if (state[rel] && !nextState[rel]) stats.staleRemote++
|
|
@@ -132,14 +136,14 @@ export async function syncTree(sftp, remoteDir, localDir, opts = {}) {
|
|
|
132
136
|
/** Recursively push local mirror → remote (three-way, ignore-aware). */
|
|
133
137
|
export async function pushTree(sftp, localDir, remoteDir, opts = {}) {
|
|
134
138
|
const {
|
|
135
|
-
maxFiles =
|
|
139
|
+
maxFiles = 2000,
|
|
136
140
|
maxFileBytes = 0,
|
|
137
141
|
isIgnored = () => false,
|
|
138
142
|
dryRun = false,
|
|
139
143
|
force = false,
|
|
140
144
|
state = {},
|
|
141
145
|
} = opts
|
|
142
|
-
const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleLocal: 0, pushed: [] }
|
|
146
|
+
const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleLocal: 0, pushed: [], truncated: false }
|
|
143
147
|
const nextState = { ...state }
|
|
144
148
|
|
|
145
149
|
const walk = async (lDir, rDir) => {
|
|
@@ -157,11 +161,11 @@ export async function pushTree(sftp, localDir, remoteDir, opts = {}) {
|
|
|
157
161
|
await walk(path.join(lDir, e.name), rp)
|
|
158
162
|
if (stats.files >= maxFiles) break
|
|
159
163
|
}
|
|
160
|
-
if (stats.files >= maxFiles) return
|
|
164
|
+
if (stats.files >= maxFiles) { stats.truncated = true; return }
|
|
161
165
|
|
|
162
166
|
const files = entries.filter((e) => !e.isDirectory())
|
|
163
167
|
await mapLimit(files, 4, async (e) => {
|
|
164
|
-
if (stats.files >= maxFiles) return
|
|
168
|
+
if (stats.files >= maxFiles) { stats.truncated = true; return }
|
|
165
169
|
const lp = path.join(lDir, e.name)
|
|
166
170
|
const rp = joinRemotePath(rDir, e.name)
|
|
167
171
|
const rel = relPathUnder(remoteDir, rp) || e.name
|
|
@@ -208,6 +212,7 @@ export async function pushTree(sftp, localDir, remoteDir, opts = {}) {
|
|
|
208
212
|
}
|
|
209
213
|
|
|
210
214
|
await walk(localDir, remoteDir)
|
|
215
|
+
if (stats.files >= maxFiles) stats.truncated = true
|
|
211
216
|
for (const rel of Object.keys(state)) {
|
|
212
217
|
if (state[rel] && !nextState[rel]) stats.staleLocal++
|
|
213
218
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
4
|
-
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with
|
|
3
|
+
"version": "0.8.20",
|
|
4
|
+
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 20 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|
|
7
7
|
"homepage": "https://github.com/flymysql/dsh-remote",
|
|
@@ -66,7 +66,6 @@
|
|
|
66
66
|
"dependencies": {
|
|
67
67
|
"ssh2": "^1.16.0",
|
|
68
68
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
69
|
-
"dsh-better-sidebar": "^0.18.1",
|
|
70
69
|
"iconv-lite": "^0.6.3"
|
|
71
70
|
},
|
|
72
71
|
"devDependencies": {
|